diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index de2baf56..da809057 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -31,6 +31,16 @@ const resolveNumber = (record: Record, keys: string[], fallback return fallback; }; +const resolveOptionalString = (record: Record, keys: string[]): string | null => { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + return null; +}; + const resolveUnitSetName = (worldState: WorldStateRow, fallback: string): string => { const config = asRecord(worldState.config); const environment = asRecord(config.environment ?? config.map); @@ -88,6 +98,7 @@ export interface BattleSimEnvironment { config: WarEngineConfig; startYear: number; scenarioEffect: ScenarioEffectKey | null; + defaultSpecialDomestic: string; } export const buildBattleSimSeedBase = (request: Pick): string | null => @@ -133,6 +144,7 @@ export const buildBattleSimEnvironment = async ( config, startYear: resolveStartYear(worldState), scenarioEffect: normalizeScenarioEffect(scenarioEnvironment.scenarioEffect), + defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']) ?? 'None', }; }; diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index 45be9ebb..6746ba4f 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -109,6 +109,7 @@ export const battleRouter = router({ config: environment.config, unitSet: environment.unitSet, scenarioEffect: environment.scenarioEffect, + defaultSpecialDomestic: environment.defaultSpecialDomestic, nationTypes: traits.nationTypes, eventDomesticTraits: traits.eventDomesticTraits, warTraits: traits.warTraits, diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index a30b9b4d..0d80113f 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -319,7 +319,10 @@ describe('battle router orchestration', () => { currentYear: 200, currentMonth: 1, tickSeconds: 600, - config: { environment: { scenarioEffect: 'event_MoreEffect' } }, + config: { + environment: { scenarioEffect: 'event_MoreEffect' }, + const: { defaultSpecialDomestic: 'che_event_신산' }, + }, meta: { scenarioMeta: { startYear: 180 } }, updatedAt: new Date('2026-01-01T00:00:00Z'), }; @@ -339,6 +342,7 @@ describe('battle router orchestration', () => { expect(context).toMatchObject({ world: { startYear: 180, currentYear: 200, currentMonth: 1 }, scenarioEffect: 'event_MoreEffect', + defaultSpecialDomestic: 'che_event_신산', config: { armPerPhase: 500, maxTrainByWar: 110, maxAtmosByWar: 150 }, }); expect(context.unitSet.crewTypes?.[0]).toMatchObject({ diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index 4e1833b2..c2de1976 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -45,6 +45,7 @@ const readImage = async (relative: string): Promise => { const simulatorFormOptions = { world: { startYear: 190, currentYear: 205, currentMonth: 8 }, + defaultSpecialDomestic: 'None', config: { maxTrainByWar: 120, maxAtmosByWar: 120, @@ -416,7 +417,8 @@ test('operates independent/game presets, imports my general, and renders battle expect(fixture.prepareResponseBytes).toEqual([expect.any(Number)]); expect(fixture.prepareResponseBytes?.every((bytes) => bytes < 128)).toBe(true); expect(fixture.preparedPayloads[0]).toMatchObject({ - attackerGeneral: { special: 'che_event_신산' }, + attackerGeneral: { special: 'None' }, + defenderGenerals: [{ special: 'None' }], }); const downloadPromise = page.waitForEvent('download'); @@ -437,7 +439,8 @@ test('operates independent/game presets, imports my general, and renders battle await expect.poll(() => fixture.preparedPayloads.length).toBe(2); expect(await readBrowserWorkerResult(page, 1)).toEqual(fixture.serverResults[1]); expect(fixture.preparedPayloads[1]).toMatchObject({ - attackerGeneral: { special: 'che_event_신산' }, + attackerGeneral: { special: 'None' }, + defenderGenerals: [{ special: 'None' }], }); if (artifactRoot) { diff --git a/app/game-frontend/src/utils/battleSimulatorTypes.ts b/app/game-frontend/src/utils/battleSimulatorTypes.ts index 19de43a1..c3b88c6f 100644 --- a/app/game-frontend/src/utils/battleSimulatorTypes.ts +++ b/app/game-frontend/src/utils/battleSimulatorTypes.ts @@ -52,6 +52,7 @@ export type BattleSimOptions = { config: WarEngineConfig; unitSet: UnitSetDefinition; scenarioEffect: string | null; + defaultSpecialDomestic: string; nationTypes: Array<{ key: string; name: string; info: string }>; eventDomesticTraits: Array<{ key: string; name: string; info: string }>; warTraits: Array<{ key: string; name: string; info: string }>; diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index fe436612..bfca6055 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -461,7 +461,7 @@ const buildGeneralPayload = ( personal: general.personal, // Ref does not expose the domestic speciality in this form. Battle requests // always use the scenario's default domestic speciality instead. - special: options.value?.eventDomesticTraits[0]?.key ?? general.special, + special: options.value?.defaultSpecialDomestic ?? 'None', special2: general.special2, crew: general.crew, crewtype: general.crewtype, diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index 1aaf3580..8b0a918d 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -1929,6 +1929,125 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }); }); + it( + 'matches the five basic arm-type matrix and wizard probabilities with Ref defaults', + { 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 }, + }; + const basicCrews = [ + { id: 1100, armType: 1, label: 'footman' }, + { id: 1200, armType: 2, label: 'archer' }, + { id: 1300, armType: 3, label: 'cavalry' }, + { id: 1400, armType: 4, label: 'wizard' }, + { id: 1501, armType: 5, label: 'siege' }, + ] as const; + for (const expected of basicCrews) { + expect( + unitSet.crewTypes?.find(({ id }) => id === expected.id)?.armType, + `${expected.label} arm type` + ).toBe(expected.armType); + } + + const cases: Array<{ + attacker: (typeof basicCrews)[number]; + defender: (typeof basicCrews)[number]; + fixture: BattleSimRequestPayload & { startYear: number }; + }> = []; + for (const attacker of basicCrews) { + for (const defender of basicCrews) { + const fixture = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + fixture.seed = `battle-differential-basic-matrix-${attacker.armType}-${defender.armType}`; + fixture.attackerGeneral.crewtype = attacker.id; + fixture.attackerGeneral.crew = 50000; + fixture.attackerGeneral.rice = 1000000; + fixture.attackerGeneral.leadership = 90; + fixture.attackerGeneral.strength = 90; + fixture.attackerGeneral.intel = 90; + fixture.attackerGeneral.personal = 'None'; + fixture.attackerGeneral.special = 'None'; + fixture.attackerGeneral.special2 = 'None'; + fixture.attackerGeneral.dex1 = 12000; + fixture.attackerGeneral.dex2 = 12000; + fixture.attackerGeneral.dex3 = 12000; + fixture.attackerGeneral.dex4 = 12000; + fixture.attackerGeneral.dex5 = 12000; + + const defenderGeneral = fixture.defenderGenerals[0]!; + defenderGeneral.crewtype = defender.id; + defenderGeneral.crew = 50000; + defenderGeneral.rice = 1000000; + defenderGeneral.leadership = 85; + defenderGeneral.strength = 85; + defenderGeneral.intel = 85; + defenderGeneral.personal = 'None'; + defenderGeneral.special = 'None'; + defenderGeneral.special2 = 'None'; + defenderGeneral.dex1 = 12000; + defenderGeneral.dex2 = 12000; + defenderGeneral.dex3 = 12000; + defenderGeneral.dex4 = 12000; + defenderGeneral.dex5 = 12000; + cases.push({ attacker, defender, fixture }); + } + } + + expect(cases).toHaveLength(25); + const references = runReferenceTraceBatch( + workspaceRoot!, + cases.map(({ fixture }) => JSON.stringify(fixture)) + ); + cases.forEach(({ attacker, defender, fixture }, index) => { + const coreEvents: WarBattleTraceEvent[] = []; + const coreLogs = createCoreLogCapture(); + let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; + processBattleSimJob( + { + ...fixture, + unitSet, + config, + time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear }, + }, + { + trace: (event) => coreEvents.push(event), + loggerFactory: coreLogs.loggerFactory, + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return coreRng.createRandUtil(); + }, + } + ); + + const reference = references[index]!; + const label = `basic-matrix.${attacker.label}.${defender.label}`; + try { + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); + assertAllLogBucketsParity(coreLogs, reference, fixture, label); + } catch (error) { + throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}`, { + cause: error, + }); + } + }); + } + ); + it('matches wizard strategy attempts, outcomes, and RNG consumption', () => { const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')