diff --git a/packages/logic/src/items/che_서적_07_사마법.ts b/packages/logic/src/items/che_서적_07_사마법.ts index 9272813c..4acb4a62 100644 --- a/packages/logic/src/items/che_서적_07_사마법.ts +++ b/packages/logic/src/items/che_서적_07_사마법.ts @@ -1,7 +1,9 @@ import { createStatItemModule } from './base.js'; import type { ItemModule } from './types.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js'; -export const itemModule: ItemModule = createStatItemModule({ +const baseModule = createStatItemModule({ key: 'che_서적_07_사마법', rawName: '사마법', slot: 'book', @@ -13,3 +15,18 @@ export const itemModule: ItemModule = createStatItemModule({ unique: true, extraInfo: '[전투] 상대의 계략을 10% 확률로 되돌림', }); + +export const itemModule: ItemModule = { + ...baseModule, + getBattlePhaseTriggerList: (context) => + context.unit + ? new WarTriggerCaller( + new che_반계시도( + context.unit, + BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 207, + 0.1 + ), + new che_반계발동(context.unit) + ) + : null, +}; diff --git a/packages/logic/src/items/che_서적_12_산해경.ts b/packages/logic/src/items/che_서적_12_산해경.ts index b41cb56f..fdf6ff68 100644 --- a/packages/logic/src/items/che_서적_12_산해경.ts +++ b/packages/logic/src/items/che_서적_12_산해경.ts @@ -1,6 +1,6 @@ import { createStatItemModule } from './base.js'; import type { ItemModule } from './types.js'; -import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js'; const baseModule = createStatItemModule({ @@ -19,5 +19,10 @@ const baseModule = createStatItemModule({ export const itemModule: ItemModule = { ...baseModule, getBattlePhaseTriggerList: (context) => - context.unit ? new WarTriggerCaller(new che_반계시도(context.unit, 0.1), new che_반계발동(context.unit)) : null, + context.unit + ? new WarTriggerCaller( + new che_반계시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM, 0.1), + new che_반계발동(context.unit) + ) + : null, }; diff --git a/packages/logic/src/items/eventBattleTrait.ts b/packages/logic/src/items/eventBattleTrait.ts index 6cd5cdf7..d513ae00 100644 --- a/packages/logic/src/items/eventBattleTrait.ts +++ b/packages/logic/src/items/eventBattleTrait.ts @@ -2,6 +2,7 @@ import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js' import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js'; import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js'; +import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js'; import type { ItemModule } from './types.js'; export const createEventBattleTraitItemModule = ( @@ -56,6 +57,14 @@ export const createEventBattleTraitItemModule = ( new che_저격발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM) ) : null; + } else if (traitModule.key === 'che_반계') { + itemModule.getBattlePhaseTriggerList = (context) => + context.unit + ? new WarTriggerCaller( + new che_반계시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM), + new che_반계발동(context.unit) + ) + : null; } else if (traitModule.key === 'che_의술') { itemModule.getBattlePhaseTriggerList = (context) => context.unit diff --git a/packages/logic/src/war/triggers/che_반계.ts b/packages/logic/src/war/triggers/che_반계.ts index a5ed58ed..ac7ed2d2 100644 --- a/packages/logic/src/war/triggers/che_반계.ts +++ b/packages/logic/src/war/triggers/che_반계.ts @@ -7,8 +7,9 @@ import type { WarUnit } from '@sammo-ts/logic/war/units.js'; export class che_반계시도 extends BaseWarUnitTrigger { private readonly prob: number; - constructor(unit: WarUnit, prob = 0.4) { - super(unit, TriggerPriority.Body + 300); + constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE, prob = 0.4) { + // Ref는 특기와 아이템의 시도를 raiseType으로 구분하고 발동은 한 번만 합친다. + super(unit, TriggerPriority.Body + 300, raiseType); this.prob = prob; } diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index c7d7229e..e11b7777 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ConstantRNG, RandUtil } from '@sammo-ts/common'; import type { City, General, Nation } from '../src/domain/entities.js'; import type { UnitSetDefinition } from '../src/world/types.js'; import { ActionLogger } from '../src/logging/actionLogger.js'; +import { traitModule as counterTrait } from '../src/actionModules/traits/war/che_반계.js'; import { WarActionPipeline } from '../src/war/actions.js'; import { resolveWarBattle } from '../src/war/engine.js'; import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js'; @@ -167,6 +168,74 @@ const buildGeneral = (strength: number): General => ({ }); describe('war triggers', () => { + it.each([ + { book: 'che_서적_07_사마법', item: null, trait: false, probabilities: [0.1] }, + { book: 'che_서적_12_산해경', item: null, trait: false, probabilities: [0.1] }, + { book: null, item: 'event_전투특기_반계', trait: false, probabilities: [0.4] }, + { book: 'che_서적_07_사마법', item: null, trait: true, probabilities: [0.4, 0.1] }, + { book: 'che_서적_12_산해경', item: null, trait: true, probabilities: [0.4, 0.1] }, + { book: null, item: 'event_전투특기_반계', trait: true, probabilities: [0.4, 0.4] }, + { book: 'che_서적_07_사마법', item: 'event_전투특기_반계', trait: true, probabilities: [0.4, 0.1, 0.4] }, + // 산해경과 비급은 Ref에서 같은 ITEM raiseType: 뒤의 비급 시도 하나만 남는다. + { book: 'che_서적_12_산해경', item: 'event_전투특기_반계', trait: true, probabilities: [0.4, 0.4] }, + ] as const)( + 'preserves counter-stratagem attempts for $book / $item / trait=$trait', + async ({ book, item, trait, probabilities }) => { + const general = buildGeneral(80); + general.role.items.book = book; + general.role.items.item = item; + const keys = [book, item].filter((key) => key !== null); + const modules = createItemActionModules(createItemModuleRegistry(await loadItemModules(keys))).war; + const pipeline = new WarActionPipeline([...(trait ? [counterTrait] : []), ...modules]); + const rng = new RandUtil(new ConstantRNG(0)); + const attempt = vi.spyOn(rng, 'nextBool').mockReturnValue(false); + const makeUnit = (attacker: boolean) => + new WarUnitGeneral( + rng, + buildConfig(), + { ...general, id: attacker ? 1 : 2 }, + buildCity(), + buildNation(), + attacker, + new WarCrewType(buildUnitSet().crewTypes![0]!), + new ActionLogger({ generalId: attacker ? 1 : 2 }), + pipeline + ); + const self = makeUnit(true); + const oppose = makeUnit(false); + const caller = pipeline.getBattlePhaseTriggerList(self.getActionContext()); + const fire = () => + caller.fire( + { rng, attacker: self, defender: oppose }, + { e_attacker: {}, e_defender: { magic: ['화계', 1.5] } } + ); + oppose.activateSkill('계략'); + fire(); + expect(attempt.mock.calls.map(([probability]) => probability)).toEqual(probabilities); + expect(self.hasActivatedSkill('반계')).toBe(false); + expect(oppose.hasActivatedSkill('계략')).toBe(true); + + attempt.mockClear().mockReturnValue(true); + const multiply = vi.spyOn(self, 'multiplyWarPowerMultiply'); + fire(); + expect(attempt).toHaveBeenCalledTimes(1); + expect(self.hasActivatedSkill('반계')).toBe(true); + expect(oppose.hasActivatedSkill('계략')).toBe(false); + expect(multiply).toHaveBeenCalledExactlyOnceWith(1.5); + + self.deactivateSkill('반계'); + attempt.mockClear(); + fire(); + expect(attempt).not.toHaveBeenCalled(); + oppose.activateSkill('계략'); + self.activateSkill('반계불가'); + fire(); + expect(attempt).not.toHaveBeenCalled(); + expect(self.hasActivatedSkill('반계')).toBe(false); + expect(oppose.hasActivatedSkill('계략')).toBe(true); + } + ); + it('applies the legacy talisman immunity to self and snipe restriction to the opponent', async () => { const attacker = buildGeneral(80); attacker.role.items.item = 'che_부적_태현청생부'; diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index e580a00e..1ab9e97f 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -2151,6 +2151,127 @@ describeWithReference('ref ↔ core2026 battle differential', () => { } ); + it('matches counter-stratagem items and stacked traits against wizards', { timeout: 180_000 }, () => { + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + const config: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 1000, + armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 }, + }; + // Ref의 raiseType별 시도 중복 제거와 단일 반사 발동을 실제 귀병 전투로 검증한다. + const variants = [ + { name: 'sima', book: 'che_서적_07_사마법' }, + { name: 'shanhai', book: 'che_서적_12_산해경' }, + { name: 'manual', item: 'event_전투특기_반계' }, + { name: 'trait', special2: 'che_반계' }, + { name: 'domestic', special: 'che_event_반계' }, + { name: 'sima-trait', book: 'che_서적_07_사마법', special2: 'che_반계' }, + { name: 'shanhai-trait', book: 'che_서적_12_산해경', special2: 'che_반계' }, + { name: 'manual-trait', item: 'event_전투특기_반계', special2: 'che_반계' }, + { name: 'sima-manual', book: 'che_서적_07_사마법', item: 'event_전투특기_반계' }, + { name: 'shanhai-manual', book: 'che_서적_12_산해경', item: 'event_전투특기_반계' }, + { + name: 'all', + book: 'che_서적_07_사마법', + item: 'event_전투특기_반계', + special2: 'che_반계', + special: 'che_event_반계', + }, + { name: 'suppressed', book: 'che_서적_07_사마법', item: 'event_전투특기_반계', special2: 'che_반계' }, + { name: 'no-magic', book: 'che_서적_07_사마법', item: 'event_전투특기_반계', special2: 'che_반계' }, + ]; + const cases = []; + for (const variant of variants) { + for (const side of ['attacker', 'defender'] as const) { + for (let seed = 0; seed < 8; seed += 1) { + const base = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + base.seed = `counter-stratagem-${seed}`; + for (const general of [base.attackerGeneral, ...base.defenderGenerals]) { + Object.assign(general, { + personal: 'None', + special: 'None', + special2: 'None', + crew: 10000, + leadership: 100, + strength: 70, + intel: 100, + dex1: 12000, + dex4: 12000, + }); + } + const self = side === 'attacker' ? base.attackerGeneral : base.defenderGenerals[0]!; + const oppose = side === 'attacker' ? base.defenderGenerals[0]! : base.attackerGeneral; + const { name, ...equipment } = variant; + Object.assign(self, equipment); + oppose.crewtype = name === 'no-magic' ? 1100 : 1400; + if (name === 'suppressed') oppose.item = 'che_진압_박혁론'; + const events: WarBattleTraceEvent[] = []; + const logs = createCoreLogCapture(); + let rng: TracingRng | null = null; + let outcome: WarBattleOutcome | null = null; + processBattleSimJob( + { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }, + { + trace: (event) => events.push(event), + loggerFactory: logs.loggerFactory, + onBattleResolved: (value) => { + outcome = value; + }, + rngFactory: (value) => { + rng = new TracingRng(LiteHashDRBG.build(value)); + return rng.createRandUtil(); + }, + } + ); + cases.push({ name, side, base, events, logs, rng, outcome }); + } + } + } + const references = runReferenceTraceBatch( + workspaceRoot!, + cases.map(({ base }) => JSON.stringify(base)) + ); + const failures: string[] = []; + const activated = new Set(); + cases.forEach((entry, index) => { + const reference = references[index]!; + const label = `${entry.name}/${entry.side}/${entry.base.seed}`; + try { + assertTraceParity(entry.events, reference, entry.rng, entry.outcome); + assertAllLogBucketsParity(entry.logs, reference, entry.base, label); + const reflected = reference.events.some((event) => event[entry.side]?.activatedSkills['반계']); + if (entry.name === 'suppressed' || entry.name === 'no-magic') { + expect(reflected, label).toBe(false); + } else if (reflected) { + activated.add(`${entry.name}/${entry.side}`); + } + } catch (error) { + failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`); + } + }); + expect(failures, failures.join('\n')).toEqual([]); + for (const variant of variants.filter(({ name }) => !['suppressed', 'no-magic'].includes(name))) { + for (const side of ['attacker', 'defender']) { + expect(activated.has(`${variant.name}/${side}`), `${variant.name}/${side} actually reflects`).toBe( + true + ); + } + } + }); + it('matches wizard strategy attempts, outcomes, and RNG consumption', () => { const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')