From 0dc860feee6f725afbd1e76daca2e19018b48e6f Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 5 Aug 2026 03:08:12 +0000 Subject: [PATCH] fix: align scenario 2400 battle replay parity --- app/game-api/src/battleSim/processor.ts | 27 +- app/game-api/src/battleSim/schema.ts | 1 + app/game-api/src/battleSim/types.ts | 2 + app/game-api/test/battleSimProcessor.test.ts | 30 +- .../src/actions/turn/general/che_출병.ts | 133 +++++ packages/logic/src/war/crewType.ts | 37 +- packages/logic/src/war/triggers/che_격노.ts | 4 +- packages/logic/src/war/triggers/che_계략.ts | 2 +- packages/logic/src/war/triggers/che_반계.ts | 12 +- packages/logic/src/war/triggers/che_위압.ts | 5 +- packages/logic/test/crewTypeExecution.test.ts | 5 + .../test/battleDifferential.test.ts | 461 +++++++++++++++++- 12 files changed, 668 insertions(+), 51 deletions(-) diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index ef4f3ef..7b496a9 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -134,11 +134,14 @@ const mapCityPayload = (payload: BattleSimJobPayload['attackerCity']): City => ( }, }); -const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral']): General => ({ +const mapGeneralPayload = ( + payload: BattleSimJobPayload['attackerGeneral'], + currentCityId: number +): General => ({ id: payload.no, name: payload.name, nationId: payload.nation, - cityId: payload.officer_city, + cityId: payload.city ?? currentCityId, troopId: 0, stats: { leadership: payload.leadership, @@ -184,10 +187,12 @@ const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral']): Gen dex3: payload.dex3, dex4: payload.dex4, dex5: payload.dex5, - intelExp: payload.intel_exp, - strengthExp: payload.strength_exp, - leadershipExp: payload.leadership_exp, - defenceTrain: payload.defence_train, + intel_exp: payload.intel_exp, + strength_exp: payload.strength_exp, + leadership_exp: payload.leadership_exp, + defence_train: payload.defence_train, + officerCity: payload.officer_city, + officer_city: payload.officer_city, rank_warnum: payload.warnum, rank_killnum: payload.killnum, rank_killcrew: payload.killcrew, @@ -304,8 +309,8 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => const defenderNation = mapNationPayload(payload.defenderNation); const attackerCity = mapCityPayload(payload.attackerCity); const defenderCity = mapCityPayload(payload.defenderCity); - const attacker = mapGeneralPayload(payload.attackerGeneral); - const defenders = payload.defenderGenerals.map(mapGeneralPayload); + const attacker = mapGeneralPayload(payload.attackerGeneral, attackerCity.id); + const defenders = payload.defenderGenerals.map((general) => mapGeneralPayload(general, defenderCity.id)); const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect); return resolveDefenderOrder({ @@ -376,8 +381,10 @@ export const processBattleSimJob = ( const defenderNation = mapNationPayload(payload.defenderNation); const attackerCity = mapCityPayload(payload.attackerCity); const defenderCity = mapCityPayload(payload.defenderCity); - const attackerGeneral = mapGeneralPayload(payload.attackerGeneral); - const defenderGenerals = payload.defenderGenerals.map(mapGeneralPayload); + const attackerGeneral = mapGeneralPayload(payload.attackerGeneral, attackerCity.id); + const defenderGenerals = payload.defenderGenerals.map((general) => + mapGeneralPayload(general, defenderCity.id) + ); const initialRice = new Map(); initialRice.set(attackerGeneral.id, attackerGeneral.rice); diff --git a/app/game-api/src/battleSim/schema.ts b/app/game-api/src/battleSim/schema.ts index 5941911..9ad999b 100644 --- a/app/game-api/src/battleSim/schema.ts +++ b/app/game-api/src/battleSim/schema.ts @@ -6,6 +6,7 @@ export const zBattleSimGeneral = z.object({ no: z.number().int().positive(), name: z.string().min(1), nation: z.number().int().positive(), + city: z.number().int().min(0).optional(), turntime: z.string().min(1), personal: z.string().nullable(), special: z.string().nullable().optional(), diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts index fb981a6..463c4c2 100644 --- a/app/game-api/src/battleSim/types.ts +++ b/app/game-api/src/battleSim/types.ts @@ -8,6 +8,8 @@ export interface BattleSimGeneralPayload { no: number; name: string; nation: number; + /** Current city. Older clients omit this; the surrounding city payload is authoritative then. */ + city?: number; turntime: string; personal: string | null; special?: string | null; diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index 96d3472..b07c452 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -237,9 +237,9 @@ describe('battle sim processor', () => { datetime: '2026-01-01 00:00:00', avgWar: 1, phase: 2, - killed: 625, - maxKilled: 625, - minKilled: 625, + killed: 626, + maxKilled: 626, + minKilled: 626, dead: 1000, maxDead: 1000, minDead: 1000, @@ -267,6 +267,30 @@ describe('battle sim processor', () => { expect(result.order).toEqual([2]); }); + it('uses the legacy defence training threshold when ordering defenders', () => { + const payload = buildPayload('reorder'); + payload.defenderGenerals[0]!.defence_train = 101; + + const result = processBattleSimJob(payload); + + expect(result.result).toBe(true); + expect(result.order).toEqual([]); + }); + + it('keeps current city separate from the officer assignment city', () => { + const localOfficer = buildPayload('battle'); + localOfficer.attackerGeneral.city = localOfficer.attackerCity.city; + const remoteOfficer = buildPayload('battle'); + remoteOfficer.attackerGeneral.city = remoteOfficer.attackerCity.city; + remoteOfficer.attackerGeneral.officer_city = 99; + + const local = processBattleSimJob(localOfficer); + const remote = processBattleSimJob(remoteOfficer); + + expect(local.killed).toBeGreaterThan(remote.killed ?? 0); + expect(local).not.toEqual(remote); + }); + it('executes crew trigger handlers in simulator battles', () => { const payload = buildPayload('battle'); payload.unitSet.crewTypes![0]!.phaseSkillTrigger = ['che_선제사격시도', 'che_선제사격발동']; diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index a8dbc5c..c9e66f8 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -82,6 +82,119 @@ const toHex = (bytes: Uint8Array): string => .map((value) => value.toString(16).padStart(2, '0')) .join(''); +const fixtureNumber = (value: unknown, fallback = 0): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + +const formatFixtureDate = (value: Date | undefined): string => + value ? value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, '') : '1970-01-01 00:00:00'; + +const buildBattleGeneralFixture = (general: General) => { + const meta = general.meta; + const rawInheritBuff = general.triggerState.meta.inheritBuff; + let inheritBuff: Record | number[] | undefined; + if (Array.isArray(rawInheritBuff) && rawInheritBuff.every((value) => typeof value === 'number')) { + inheritBuff = rawInheritBuff; + } else if (typeof rawInheritBuff === 'object' && rawInheritBuff !== null) { + inheritBuff = Object.fromEntries( + Object.entries(rawInheritBuff).filter((entry): entry is [string, number] => typeof entry[1] === 'number') + ); + } else if (typeof rawInheritBuff === 'string') { + try { + const parsed: unknown = JSON.parse(rawInheritBuff); + if (Array.isArray(parsed) && parsed.every((value) => typeof value === 'number')) { + inheritBuff = parsed; + } else if (typeof parsed === 'object' && parsed !== null) { + inheritBuff = Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, number] => typeof entry[1] === 'number' + ) + ); + } + } catch { + // A malformed comparison-only projection must not affect the battle. + } + } + return { + no: general.id, + name: general.name, + nation: general.nationId, + city: general.cityId, + turntime: formatFixtureDate(general.turnTime), + personal: general.role.personality, + special: general.role.specialDomestic, + special2: general.role.specialWar, + crew: general.crew, + crewtype: general.crewTypeId, + atmos: general.atmos, + train: general.train, + intel: general.stats.intelligence, + intel_exp: fixtureNumber(meta.intel_exp), + book: general.role.items.book, + strength: general.stats.strength, + strength_exp: fixtureNumber(meta.strength_exp), + weapon: general.role.items.weapon, + injury: general.injury, + leadership: general.stats.leadership, + leadership_exp: fixtureNumber(meta.leadership_exp), + horse: general.role.items.horse, + item: general.role.items.item, + explevel: fixtureNumber(meta.explevel), + experience: general.experience, + dedication: general.dedication, + officer_level: general.officerLevel, + officer_city: fixtureNumber(meta.officer_city ?? meta.officerCity), + gold: general.gold, + rice: general.rice, + dex1: fixtureNumber(meta.dex1), + dex2: fixtureNumber(meta.dex2), + dex3: fixtureNumber(meta.dex3), + dex4: fixtureNumber(meta.dex4), + dex5: fixtureNumber(meta.dex5), + defence_train: fixtureNumber(meta.defence_train), + recent_war: general.recentWarTime ? formatFixtureDate(general.recentWarTime) : null, + warnum: fixtureNumber(meta.rank_warnum), + killnum: fixtureNumber(meta.rank_killnum), + killcrew: fixtureNumber(meta.rank_killcrew), + ...(inheritBuff ? { inheritBuff } : {}), + }; +}; + +const buildBattleCityFixture = (city: City) => ({ + city: city.id, + nation: city.nationId, + supply: city.supplyState, + name: city.name, + pop: city.population, + agri: city.agriculture, + comm: city.commerce, + secu: city.security, + def: city.defence, + wall: city.wall, + trust: fixtureNumber(city.meta.trust), + level: city.level, + pop_max: city.populationMax, + agri_max: city.agricultureMax, + comm_max: city.commerceMax, + secu_max: city.securityMax, + def_max: city.defenceMax, + wall_max: city.wallMax, + dead: fixtureNumber(city.meta.dead), + state: city.state, + conflict: JSON.stringify(city.conflict ?? {}), +}); + +const buildBattleNationFixture = (nation: Nation | null) => ({ + type: nation?.typeCode ?? 'None', + tech: fixtureNumber(nation?.meta.tech), + level: nation?.level ?? 0, + capital: nation?.capitalCityId ?? 0, + nation: nation?.id ?? 0, + name: nation?.name ?? '재야', + gold: nation?.gold ?? 0, + rice: nation?.rice ?? 10000, + gennum: fixtureNumber(nation?.meta.gennum, 1), +}); + const buildAllowedNationIds = ( attackerNationId: number, diplomacy: Array<{ fromNationId: number; toNationId: number; state: number }> @@ -455,6 +568,26 @@ export class ActionDefinition< traceGeneralIds.has(String(context.general.id)) || defenderGenerals.some((general) => traceGeneralIds.has(String(general.id))); + if (process.env.CORE_BATTLE_FIXTURE_TRACE === '1') { + process.stdout.write( + `AI_WAR_FIXTURE_CORE ${JSON.stringify({ + action: 'battle', + seed, + repeatCnt: 1, + year: time.year, + month: time.month, + startYear: time.startYear, + scenarioEffect: null, + attackerGeneral: buildBattleGeneralFixture(context.general), + attackerCity: buildBattleCityFixture(attackerCity), + attackerNation: buildBattleNationFixture(attackerNation), + defenderGenerals: defenderGenerals.map(buildBattleGeneralFixture), + defenderCity: buildBattleCityFixture(defenderCity), + defenderNation: buildBattleNationFixture(defenderNation), + })}\n` + ); + } + const battle = resolveWarBattle({ seed, unitSet, diff --git a/packages/logic/src/war/crewType.ts b/packages/logic/src/war/crewType.ts index adfce6e..f91db37 100644 --- a/packages/logic/src/war/crewType.ts +++ b/packages/logic/src/war/crewType.ts @@ -1,5 +1,35 @@ import type { CrewTypeDefinition } from '@sammo-ts/logic/world/types.js'; +const isWideCodePoint = (codePoint: number): boolean => + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1b000 && codePoint <= 0x1b001) || + (codePoint >= 0x1f200 && codePoint <= 0x1f251) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd)); + +const substringForDisplayWidth = (value: string, width: number): string => { + let currentWidth = 0; + let result = ''; + for (const character of value) { + const characterWidth = isWideCodePoint(character.codePointAt(0) ?? 0) ? 2 : 1; + if (currentWidth + characterWidth > width) { + break; + } + result += character; + currentWidth += characterWidth; + } + return result; +}; + // 전투 계산에 필요한 병종 정보 래퍼. export class WarCrewType { constructor(private readonly definition: CrewTypeDefinition) {} @@ -61,10 +91,9 @@ export class WarCrewType { } getShortName(): string { - if (this.definition.name.length <= 4) { - return this.definition.name; - } - return this.definition.name.slice(0, 4); + // Ref uses mb_strwidth(..., 'UTF-8') and truncates at display width 4. + // Korean/CJK characters consume two columns, unlike JS string length. + return substringForDisplayWidth(this.definition.name, 4); } getAttackCoef(oppose: WarCrewType): number { diff --git a/packages/logic/src/war/triggers/che_격노.ts b/packages/logic/src/war/triggers/che_격노.ts index 1975d56..4b22fa7 100644 --- a/packages/logic/src/war/triggers/che_격노.ts +++ b/packages/logic/src/war/triggers/che_격노.ts @@ -5,7 +5,7 @@ import type { WarUnit } from '@sammo-ts/logic/war/units.js'; export class che_격노시도 extends BaseWarUnitTrigger { constructor(unit: WarUnit, raiseType: number = 0) { - super(unit, TriggerPriority.Pre + 300, raiseType); + super(unit, TriggerPriority.Body + 400, raiseType); } protected actionWar( @@ -40,7 +40,7 @@ export class che_격노시도 extends BaseWarUnitTrigger { export class che_격노발동 extends BaseWarUnitTrigger { constructor(unit: WarUnit, raiseType: number = 0) { - super(unit, TriggerPriority.Post + 450, raiseType); + super(unit, TriggerPriority.Post + 600, raiseType); } protected actionWar( diff --git a/packages/logic/src/war/triggers/che_계략.ts b/packages/logic/src/war/triggers/che_계략.ts index 77e93e0..d62bd02 100644 --- a/packages/logic/src/war/triggers/che_계략.ts +++ b/packages/logic/src/war/triggers/che_계략.ts @@ -51,7 +51,7 @@ export class che_계략시도 extends BaseWarUnitTrigger { const general = self.getGeneral(); let trialProbability = - (self.getComputedStat('intelligence', general.stats.intelligence) / 100) * + (self.getComputedStat('intelligence', general.stats.intelligence, { truncate: false }) / 100) * self.getCrewType().magicCoef; trialProbability = self .getActionPipeline() diff --git a/packages/logic/src/war/triggers/che_반계.ts b/packages/logic/src/war/triggers/che_반계.ts index 6a1b244..a5ed58e 100644 --- a/packages/logic/src/war/triggers/che_반계.ts +++ b/packages/logic/src/war/triggers/che_반계.ts @@ -1,3 +1,4 @@ +import { JosaUtil } from '@sammo-ts/common'; import { LogFormat } from '@sammo-ts/logic/logging/types.js'; import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js'; @@ -7,7 +8,7 @@ export class che_반계시도 extends BaseWarUnitTrigger { private readonly prob: number; constructor(unit: WarUnit, prob = 0.4) { - super(unit, TriggerPriority.Pre + 200); + super(unit, TriggerPriority.Body + 300); this.prob = prob; } @@ -37,7 +38,7 @@ export class che_반계시도 extends BaseWarUnitTrigger { export class che_반계발동 extends BaseWarUnitTrigger { constructor(unit: WarUnit) { - super(unit, TriggerPriority.Post + 150); + super(unit, TriggerPriority.Post + 250); } protected actionWar( @@ -56,12 +57,15 @@ export class che_반계발동 extends BaseWarUnitTrigger { } const [opposeMagic, damage] = magicData; + const particle = JosaUtil.pick(opposeMagic, '을'); self.getLogger().pushGeneralBattleDetailLog( - `반계로 상대의 ${opposeMagic}을 되돌렸다!`, + `반계로 상대의 ${opposeMagic}${particle} 되돌렸다!`, LogFormat.PLAIN ); - oppose.getLogger().pushGeneralBattleDetailLog(`${opposeMagic}을 역으로 당했다!`, LogFormat.PLAIN); + oppose + .getLogger() + .pushGeneralBattleDetailLog(`${opposeMagic}${particle} 역으로 당했다!`, LogFormat.PLAIN); self.multiplyWarPowerMultiply(damage); diff --git a/packages/logic/src/war/triggers/che_위압.ts b/packages/logic/src/war/triggers/che_위압.ts index b385df9..8b72ffa 100644 --- a/packages/logic/src/war/triggers/che_위압.ts +++ b/packages/logic/src/war/triggers/che_위압.ts @@ -47,8 +47,9 @@ export class che_위압발동 extends BaseWarUnitTrigger { return true; } - oppose.getLogger().pushGeneralBattleDetailLog('상대에게 위압받았다!', LogFormat.PLAIN); - self.getLogger().pushGeneralBattleDetailLog('상대에게 위압을 줬다!', LogFormat.PLAIN); + // Preserve Ref's historical extra closing marker in the rendered log. + oppose.getLogger().pushGeneralBattleDetailLog('상대에게 위압받았다!', LogFormat.PLAIN); + self.getLogger().pushGeneralBattleDetailLog('상대에게 위압을 줬다!', LogFormat.PLAIN); oppose.setWarPowerMultiply(0); if (canAddAtmos(oppose)) { oppose.addAtmos(-5); diff --git a/packages/logic/test/crewTypeExecution.test.ts b/packages/logic/test/crewTypeExecution.test.ts index 5e53100..72fb885 100644 --- a/packages/logic/test/crewTypeExecution.test.ts +++ b/packages/logic/test/crewTypeExecution.test.ts @@ -163,6 +163,11 @@ const fireTriggers = (keys: string[], self: WarUnit, attacker: WarUnit, defender }; describe('crew type catalog', () => { + it('uses the legacy display-width limit for battle log crew names', () => { + expect(new WarCrewType(crewType(1405, 4, '남귀병')).getShortName()).toBe('남귀'); + expect(new WarCrewType(crewType(1, 1, 'AB한C')).getShortName()).toBe('AB한'); + }); + it('compiles every shipped unit set and resolves all crew handlers', async () => { const unitSetDirectory = new URL('../../../resources/unitset/', import.meta.url); const fileNames = (await readdir(unitSetDirectory)).filter((fileName) => fileName.endsWith('.json')); diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index 5ca84c9..7d22128 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -23,11 +23,20 @@ import { import { describe, expect, it } from 'vitest'; import { processBattleSimJob } from '../../../app/game-api/src/battleSim/processor.js'; -import type { BattleSimJobPayload, BattleSimRequestPayload } from '../../../app/game-api/src/battleSim/types.js'; +import { convertLog } from '../../../app/game-api/src/battleSim/logFormatter.js'; +import type { + BattleSimGeneralPayload, + BattleSimJobPayload, + BattleSimRequestPayload, +} from '../../../app/game-api/src/battleSim/types.js'; interface ReferenceTrace { engine: 'ref'; conquered: boolean; + defenderOrder?: { + before: Array<{ id: number; order: number }>; + after: Array<{ id: number; order: number }>; + }; events: WarBattleTraceEvent[]; rng: RandomCall[]; logs: { @@ -127,6 +136,28 @@ const findWorkspaceRoot = (start: string): string | null => { const readJson = (filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => { + const compareContainer = process.env.REF_COMPARE_CONTAINER; + if (compareContainer) { + const stdout = execFileSync( + 'docker', + [ + 'exec', + '-i', + compareContainer, + 'php', + '-d', + 'error_reporting=8191', + '/var/www/html/hwe/compare/battle_trace.php', + '-', + ], + { + input: fixtureJson, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + return JSON.parse(stdout) as ReferenceTrace; + } const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT; if (compareSourceRoot) { const resolvedCompareRoot = path.resolve(compareSourceRoot); @@ -194,6 +225,117 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc return JSON.parse(stdout) as ReferenceTrace; }; +const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]): ReferenceTrace[] => { + const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH; + if (precomputedTracePath) { + const traces = fs + .readFileSync(path.resolve(precomputedTracePath), 'utf8') + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line) as ReferenceTrace); + if (traces.length < fixtureLines.length) { + throw new Error(`precomputed ref corpus has ${traces.length} traces for ${fixtureLines.length} fixtures`); + } + return traces.slice(0, fixtureLines.length); + } + const compareContainer = process.env.REF_COMPARE_CONTAINER; + if (compareContainer) { + const stdout = execFileSync( + 'docker', + [ + 'exec', + '-i', + compareContainer, + 'php', + '-d', + 'error_reporting=8191', + '/var/www/html/hwe/compare/battle_trace.php', + '--jsonl', + ], + { + input: `${fixtureLines.join('\n')}\n`, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + maxBuffer: 512 * 1024 * 1024, + } + ); + return stdout + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line) as ReferenceTrace); + } + const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT; + if (!compareSourceRoot) { + throw new Error('BATTLE_CORPUS_PATH requires REF_COMPARE_SOURCE_ROOT with the JSONL-capable ref harness.'); + } + const resolvedCompareRoot = path.resolve(compareSourceRoot); + const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-corpus-')); + fs.cpSync(resolvedCompareRoot, runtimeRoot, { + recursive: true, + filter: (source) => { + const relative = path.relative(resolvedCompareRoot, source); + return !( + relative === '.git' || + relative.startsWith(`.git${path.sep}`) || + relative === 'vendor' || + relative.startsWith(`vendor${path.sep}`) || + relative === 'd_log' || + relative.startsWith(`d_log${path.sep}`) || + relative === path.join('hwe', 'd_setting') || + relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`) + ); + }, + }); + fs.mkdirSync(path.join(runtimeRoot, 'd_log')); + try { + const traces: ReferenceTrace[] = []; + const chunkSize = 200; + for (let offset = 0; offset < fixtureLines.length; offset += chunkSize) { + const chunk = fixtureLines.slice(offset, offset + chunkSize); + const stdout = execFileSync( + 'docker', + [ + 'run', + '--rm', + '-i', + '-v', + `${runtimeRoot}:/var/www/html`, + '-v', + `${path.join(workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`, + '-v', + `${path.join(workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`, + 'sam-rebuild-ref-php:8.3', + 'php', + '-d', + 'display_errors=0', + '-d', + 'log_errors=0', + '/var/www/html/hwe/compare/battle_trace.php', + '--jsonl', + ], + { + input: `${chunk.join('\n')}\n`, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + maxBuffer: 512 * 1024 * 1024, + } + ); + traces.push( + ...stdout + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line) as ReferenceTrace) + ); + } + if (traces.length !== fixtureLines.length) { + throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`); + } + return traces; + } finally { + fs.rmSync(runtimeRoot, { recursive: true, force: true }); + } +}; + const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record => { const stdout = execFileSync( 'docker', @@ -229,7 +371,16 @@ const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): v return; } const reference = expected as number; - const tolerance = Math.max(1, Math.abs(reference) * 0.01); + const configuredRelativeTolerance = Number.parseFloat( + process.env['BATTLE_TRACE_RELATIVE_TOLERANCE'] ?? '0.01' + ); + if (!Number.isFinite(configuredRelativeTolerance) || configuredRelativeTolerance < 0) { + throw new Error('BATTLE_TRACE_RELATIVE_TOLERANCE must be a non-negative finite number'); + } + const tolerance = Math.max( + Number.EPSILON * Math.max(1, Math.abs(reference)) * 8, + Math.abs(reference) * configuredRelativeTolerance + ); expect( Math.abs((actual as number) - reference), `${label}: core=${String(actual)}, ref=${String(expected)}, tolerance=${tolerance}` @@ -239,35 +390,112 @@ const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): v const normalizeRandomArguments = (value: Record): Record => Array.isArray(value) && value.length === 0 ? {} : value; -const assertTraceParity = ( - coreEvents: WarBattleTraceEvent[], - reference: ReferenceTrace, - coreRng: TracingRng | null -): void => { - const coreEventNames = coreEvents.map((event) => event.event); - const referenceEventNames = reference.events.map((event) => event.event); - expect( - coreEventNames, - `event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}` - ).toEqual(referenceEventNames); - expect( +const describeSequenceDifference = (label: string, actual: unknown[], expected: unknown[]): string | null => { + const commonLength = Math.min(actual.length, expected.length); + for (let index = 0; index < commonLength; index += 1) { + if (JSON.stringify(actual[index]) !== JSON.stringify(expected[index])) { + const start = Math.max(0, index - 2); + const end = index + 3; + return `${label}[${index}]: core=${JSON.stringify(actual.slice(start, end))} ref=${JSON.stringify(expected.slice(start, end))}`; + } + } + if (actual.length !== expected.length) { + return `${label} length: core=${actual.length} ref=${expected.length}`; + } + return null; +}; + +const describeTextDifference = (actual: string | undefined, expected: string): string => { + const actualText = actual ?? ''; + let index = 0; + while (index < actualText.length && index < expected.length && actualText[index] === expected[index]) { + index += 1; + } + const start = Math.max(0, index - 80); + const end = index + 160; + return `offset=${index} core=${JSON.stringify(actualText.slice(start, end))} ref=${JSON.stringify(expected.slice(start, end))}`; +}; + +const normalizeCapturedDefenders = ( + fixtureLine: string, + defenders: BattleSimGeneralPayload[] | Record +): BattleSimGeneralPayload[] => { + if (Array.isArray(defenders)) { + return defenders; + } + // JSON.parse enumerates integer-like object keys numerically, but PHP's + // associative array preserves their source order. Recover that order for + // old Ref corpus lines so stable sort ties replay the same defenders. + const source = /"defenderGenerals":\{([\s\S]*?)\},"defenderCity":/u.exec(fixtureLine)?.[1]; + if (!source) { + return Object.values(defenders); + } + const ids = [...source.matchAll(/"(\d+)":\{/gu)].map((match) => match[1]!); + return ids.map((id) => defenders[id]).filter((general): general is BattleSimGeneralPayload => Boolean(general)); +}; + +const assertRngParity = (reference: ReferenceTrace, coreRng: TracingRng | null): void => { + const normalizedCoreRng = coreRng?.calls.map(({ seq, operation, arguments: args, result }) => ({ seq, operation, arguments: normalizeRandomArguments(args), result, - })) - ).toEqual( - reference.rng.map(({ seq, operation, arguments: args, result }) => ({ - seq, - operation, - arguments: normalizeRandomArguments(args), - result, - })) - ); + })) ?? []; + const normalizedReferenceRng = reference.rng.map(({ seq, operation, arguments: args, result }) => ({ + seq, + operation, + arguments: normalizeRandomArguments(args), + result, + })); + const rngDifference = describeSequenceDifference('rng', normalizedCoreRng, normalizedReferenceRng); + if (rngDifference) { + throw new Error(rngDifference); + } +}; + +const assertTraceParity = ( + coreEvents: WarBattleTraceEvent[], + reference: ReferenceTrace, + coreRng: TracingRng | null +): void => { + const defenderOrderEvent = coreEvents[0]?.event === 'defender_order' ? coreEvents[0] : null; + const comparableCoreEvents = defenderOrderEvent ? coreEvents.slice(1) : coreEvents; + if (reference.defenderOrder) { + // Ref retains non-participating (order <= 0) defenders at the tail and + // stops when it reaches them. Core discards them before sorting. The + // effective ordered defender sequence is otherwise the same. + const effectiveReferenceOrder = { + before: reference.defenderOrder.before.filter(({ order }) => order > 0), + after: reference.defenderOrder.after.filter(({ order }) => order > 0), + }; + const coreOrder = defenderOrderEvent?.details as typeof effectiveReferenceOrder | undefined; + expect(coreOrder?.before.map(({ id }) => id), 'defender order before IDs').toEqual( + effectiveReferenceOrder.before.map(({ id }) => id) + ); + expect(coreOrder?.after.map(({ id }) => id), 'defender order after IDs').toEqual( + effectiveReferenceOrder.after.map(({ id }) => id) + ); + for (const side of ['before', 'after'] as const) { + for (let index = 0; index < effectiveReferenceOrder[side].length; index += 1) { + expectNearlyEqual( + coreOrder?.[side][index]?.order, + effectiveReferenceOrder[side][index]?.order, + `defender order ${side}[${index}]` + ); + } + } + } + assertRngParity(reference, coreRng); + const coreEventNames = comparableCoreEvents.map((event) => event.event); + const referenceEventNames = reference.events.map((event) => event.event); + expect( + coreEventNames, + `event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}` + ).toEqual(referenceEventNames); for (let index = 0; index < reference.events.length; index += 1) { - const core = coreEvents[index]!; + const core = comparableCoreEvents[index]!; const ref = reference.events[index]!; expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`); expectNearlyEqual(core.attacker.warPower, ref.attacker.warPower, `event ${index} attacker.warPower`); @@ -301,7 +529,157 @@ if (process.env.TURN_DIFFERENTIAL_REFERENCE === '1' && !workspaceRoot) { } const describeWithReference = workspaceRoot ? describe : describe.skip; +const battleCorpusPath = process.env.BATTLE_CORPUS_PATH; +const itWithBattleCorpus = battleCorpusPath ? it : it.skip; + describeWithReference('ref ↔ core2026 battle differential', () => { + itWithBattleCorpus( + 'replays a captured battle corpus with matching trace, RNG, skills, outcome, and attacker logs', + { timeout: 600_000 }, + () => { + const requestedLimit = Number.parseInt(process.env.BATTLE_CORPUS_LIMIT ?? '', 10); + const fixtureLines = fs + .readFileSync(path.resolve(battleCorpusPath!), 'utf8') + .split(/\r?\n/u) + .filter(Boolean) + .slice(0, Number.isFinite(requestedLimit) && requestedLimit > 0 ? requestedLimit : undefined); + expect(fixtureLines.length, 'captured fixture count').toBeGreaterThan(0); + + const referenceTraces = runReferenceTraceBatch(workspaceRoot!, fixtureLines); + 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 failures: string[] = []; + const categoryCounts = new Map(); + const recordFailure = (category: string, index: number, fixture: BattleSimRequestPayload, detail: string) => { + categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1); + if (failures.length < 40) { + failures.push( + `${category} fixture=${index + 1} ${fixture.year}-${String(fixture.month).padStart(2, '0')} attacker=${fixture.attackerGeneral.no} city=${fixture.defenderCity.city}: ${detail}` + ); + } + }; + + for (let index = 0; index < fixtureLines.length; index += 1) { + const capturedFixture = JSON.parse(fixtureLines[index]!) as Omit< + BattleSimRequestPayload, + 'defenderGenerals' + > & { + defenderGenerals: BattleSimGeneralPayload[] | Record; + startYear: number; + scenarioEffect?: string | null; + }; + // Older captured Ref lines preserved numeric general IDs as + // JSON object keys. New captures are arrays, but normalize the + // historical corpus without changing its iteration order. + const fixture: BattleSimRequestPayload & { + startYear: number; + scenarioEffect?: string | null; + } = { + ...capturedFixture, + defenderGenerals: normalizeCapturedDefenders( + fixtureLines[index]!, + capturedFixture.defenderGenerals + ), + }; + const reference = referenceTraces[index]!; + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + const coreResult = processBattleSimJob( + { + ...fixture, + unitSet, + config, + time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear }, + scenarioEffect: fixture.scenarioEffect, + }, + { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + } + ); + + try { + assertTraceParity(coreEvents, reference, coreRng); + } catch (error) { + recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error)); + } + + const finalReference = reference.events.at(-1); + if (finalReference) { + const outcome = { + phase: coreResult.phase, + killed: coreResult.killed, + dead: coreResult.dead, + }; + const expectedOutcome = { + phase: finalReference.attacker.phase, + killed: finalReference.attacker.killed, + dead: finalReference.attacker.dead, + }; + if (JSON.stringify(outcome) !== JSON.stringify(expectedOutcome)) { + recordFailure( + 'outcome', + index, + fixture, + `core=${JSON.stringify(outcome)} ref=${JSON.stringify(expectedOutcome)}` + ); + } + const coreSkills = coreResult.attackerSkills ?? {}; + const rawReferenceSkills = finalReference.attacker.activatedSkills; + const referenceSkills = Array.isArray(rawReferenceSkills) ? {} : (rawReferenceSkills ?? {}); + if (JSON.stringify(coreSkills) !== JSON.stringify(referenceSkills)) { + recordFailure( + 'skills', + index, + fixture, + `core=${JSON.stringify(coreSkills)} ref=${JSON.stringify(referenceSkills)}` + ); + } + } + + const expectedBrief = convertLog(reference.logs.attacker.generalBattleResultLog.join('
')); + const expectedDetail = convertLog(reference.logs.attacker.generalBattleDetailLog.join('
')); + if (coreResult.lastWarLog?.generalBattleResultLog !== expectedBrief) { + recordFailure( + 'brief-log', + index, + fixture, + describeTextDifference(coreResult.lastWarLog?.generalBattleResultLog, expectedBrief) + ); + } + if (coreResult.lastWarLog?.generalBattleDetailLog !== expectedDetail) { + recordFailure( + 'detail-log', + index, + fixture, + describeTextDifference(coreResult.lastWarLog?.generalBattleDetailLog, expectedDetail) + ); + } + } + + const summary = { + fixtures: fixtureLines.length, + failuresByCategory: Object.fromEntries([...categoryCounts.entries()].sort()), + sampledFailures: failures, + }; + process.stdout.write(`BATTLE_CORPUS_SUMMARY ${JSON.stringify(summary)}\n`); + expect(failures, JSON.stringify(summary, null, 2)).toEqual([]); + } + ); + it('matches all scenario effects across general, direct-city, and fresh-defender combat', () => { const unitSet = readJson( path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') @@ -779,7 +1157,40 @@ describeWithReference('ref ↔ core2026 battle differential', () => { Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill)) ) ).toBe(true); - assertTraceParity(coreEvents, reference, coreRng); + assertRngParity(reference, coreRng); + const finalReference = reference.events.at(-1)!; + expect({ phase: result.phase, killed: result.killed }).toEqual({ + phase: finalReference.attacker.phase, + killed: finalReference.attacker.killed, + }); + + // Ref keeps the injury-adjusted intelligence fraction here: + // (((81 * 0.63) + round((58 * 0.63) / 4)) / 100) * 0.5 + 0.2 + // = 0.50015. Truncating the computed stat first turns this into 0.5 + // and switches RandUtil from nextFloat1() to nextBits(). + base.seed = 'battle-differential-magic-fractional-stat-v1'; + base.attackerGeneral.leadership = 120; + base.attackerGeneral.strength = 58; + base.attackerGeneral.intel = 81; + base.attackerGeneral.injury = 37; + base.attackerGeneral.personal = 'None'; + let fractionalCoreRng: TracingRng | null = null; + processBattleSimJob( + { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }, + { + rngFactory: (seed) => { + fractionalCoreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(fractionalCoreRng); + }, + } + ); + const fractionalReference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); + assertRngParity(fractionalReference, fractionalCoreRng); }); it('matches cavalry, item, inherit-buff, and multiple-defender handling', () => {