import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; import { ITEM_KEYS, DOMESTIC_TRAIT_KEYS, EVENT_DOMESTIC_TRAIT_KEYS, NATION_TRAIT_KEYS, PERSONALITY_TRAIT_KEYS, WAR_TRAIT_KEYS, loadDomesticTraitModules, loadItemModules, loadNationTraitModules, loadPersonalityTraitModules, loadWarTraitModules, ActionLogger, formatLogText, LogCategory, LogFormat, LogScope, type LogEntryDraft, type UnitSetDefinition, type WarBattleOutcome, type WarBattleTraceEvent, type WarBattleTraceUnitSnapshot, type WarEngineConfig, } from '@sammo-ts/logic'; import { describe, expect, it } from 'vitest'; import { processBattleSimJob } from '../../../app/game-api/src/battleSim/processor.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'; seed: string; fixtureIdentity: FixtureIdentity; conquered: boolean; attacker: WarBattleTraceEvent['attacker']; city: WarBattleTraceEvent['city']; finishedDefenders: WarBattleTraceEvent['attacker'][]; defenderOrder?: { before: Array<{ id: number; order: number }>; after: Array<{ id: number; order: number }>; }; events: WarBattleTraceEvent[]; rng: RandomCall[]; boolRng: BoolRandomCall[]; logs: { attacker: ReferenceLogBuckets; defenders: Record; city: ReferenceLogBuckets; }; } interface FixtureIdentity { schemaVersion: 1; seed: string; sha256: string; } interface BoolRandomCall { rngSeq: number; probability: number; result: boolean; } interface ReferenceLogBuckets { generalHistoryLog: string[]; generalActionLog: string[]; generalBattleResultLog: string[]; generalBattleDetailLog: string[]; nationalHistoryLog: string[]; globalHistoryLog: string[]; globalActionLog: string[]; } interface CapturedCoreLogger { generalId?: number; nationId?: number; entries: LogEntryDraft[]; } interface CoreLogCapture { loggerFactory: (options: { generalId?: number; nationId?: number }) => ActionLogger; byGeneralId: Map; city: CapturedCoreLogger | null; } class ComparisonCapturingActionLogger extends ActionLogger { public constructor( options: { generalId?: number; nationId?: number }, private readonly capture: (entries: LogEntryDraft[]) => void ) { super(options); } public override flush(): LogEntryDraft[] { const entries = super.flush(); this.capture(entries); return entries; } public override rollback(): LogEntryDraft[] { const entries = super.rollback(); this.capture(entries); return entries; } } const createCoreLogCapture = (): CoreLogCapture => { const capture: CoreLogCapture = { byGeneralId: new Map(), city: null, loggerFactory: () => { throw new Error('loggerFactory is not initialized'); }, }; capture.loggerFactory = (options) => { const bucket: CapturedCoreLogger = { ...options, entries: [] }; if (options.generalId === undefined) { if (capture.city) { throw new Error('battle comparison created more than one city logger'); } capture.city = bucket; } else { if (capture.byGeneralId.has(options.generalId)) { throw new Error(`battle comparison duplicated general logger ${options.generalId}`); } capture.byGeneralId.set(options.generalId, bucket); } return new ComparisonCapturingActionLogger(options, (entries) => bucket.entries.push(...entries)); }; return capture; }; interface RandomCall { seq: number; operation: string; arguments: Record; result: unknown; } interface ReferenceItemMetadata { rawName: string; name: string; info: string; cost: number | null; buyable: boolean; consumable: boolean; reqSecu: number; } type ReferenceTraitCatalog = Record< 'nation' | 'domestic' | 'war' | 'personality', Record >; class TracingRng implements RNG { public readonly calls: RandomCall[] = []; public readonly boolCalls: BoolRandomCall[] = []; public constructor(private readonly inner: RNG) {} public getMaxInt(): number { return this.inner.getMaxInt(); } public nextBytes(bytes: number): Uint8Array { const result = this.inner.nextBytes(bytes); this.record('nextBytes', { bytes }, Buffer.from(result).toString('hex')); return result; } public nextBits(bits: number): Uint8Array { const result = this.inner.nextBits(bits); this.record('nextBits', { bits }, Buffer.from(result).toString('hex')); return result; } public nextInt(max?: number): number { const result = this.inner.nextInt(max); this.record('nextInt', { maxInclusive: max ?? null }, result); return result; } public nextFloat1(): number { const result = this.inner.nextFloat1(); this.record('nextFloat1', {}, result); return result; } public createRandUtil(): RandUtil { const calls = this.calls; const boolCalls = this.boolCalls; return new (class extends RandUtil { public override nextBool(probability: number = 0.5): boolean { const rngSeq = calls.length; const result = super.nextBool(probability); boolCalls.push({ rngSeq, probability, result }); return result; } })(this); } private record(operation: string, args: Record, result: unknown): void { this.calls.push({ seq: this.calls.length, operation, arguments: args, result }); } } const findWorkspaceRoot = (start: string): string | null => { let current = path.resolve(start); while (true) { if ( fs.existsSync(path.join(current, 'docker_compose_files/reference/compose.yml')) && fs.existsSync(path.join(current, 'ref/sam/hwe/compare/battle_trace.php')) ) { return current; } const parent = path.dirname(current); if (parent === current) { return null; } current = parent; } }; const readJson = (filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); const assertFixtureGeneralCityContract = (fixtureJson: string, label = 'battle fixture'): FixtureIdentity => { const normalizedFixtureJson = fixtureJson.trim(); const fixture = JSON.parse(normalizedFixtureJson) as unknown; if (!isRecord(fixture)) { throw new Error(`${label}: fixture root must be an object`); } const assertSide = (side: 'attacker' | 'defender', general: unknown, city: unknown, index?: number): void => { const suffix = index === undefined ? '' : `[${index}]`; if (!isRecord(general) || !isRecord(city)) { throw new Error(`${label}: ${side}${suffix} general/city must be objects`); } const generalCity = general['city']; const currentCity = city['city']; if (!Number.isSafeInteger(generalCity) || (generalCity as number) <= 0) { throw new Error(`${label}: ${side}General${suffix}.city must be an explicit positive integer`); } if (!Number.isSafeInteger(currentCity) || (currentCity as number) <= 0) { throw new Error(`${label}: ${side}City.city must be a positive integer`); } if (generalCity !== currentCity) { throw new Error( `${label}: ${side}General${suffix}.city=${String(generalCity)} must equal current city ${String(currentCity)}` ); } }; assertSide('attacker', fixture['attackerGeneral'], fixture['attackerCity']); const defenderCity = fixture['defenderCity']; const rawDefenders = fixture['defenderGenerals']; const defenders = Array.isArray(rawDefenders) ? rawDefenders : isRecord(rawDefenders) ? Object.values(rawDefenders) : null; if (!defenders) { throw new Error(`${label}: defenderGenerals must be an array or ID-keyed object`); } defenders.forEach((general, index) => assertSide('defender', general, defenderCity, index)); const seed = typeof fixture['seed'] === 'string' ? fixture['seed'] : 'battle-differential'; return { schemaVersion: 1, seed, sha256: sha256(normalizedFixtureJson), }; }; const assertReferenceFixtureIdentity = ( reference: ReferenceTrace, fixtureJson: string, label = 'reference trace' ): void => { const expected = assertFixtureGeneralCityContract(fixtureJson, label); expect(reference.fixtureIdentity, `${label}: fixture identity`).toEqual(expected); expect(reference.seed, `${label}: seed`).toBe(expected.seed); }; const referenceRuntimeCopyFilter = (resolvedCompareRoot: string, source: string): boolean => { 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}`) ); }; const assertSafeDockerNetworkName = (network: string): string => { if (!/^[A-Za-z0-9_.-]+$/u.test(network)) { throw new Error('Reference Docker network name contains unsupported characters.'); } return network; }; const resolveReferenceDockerNetwork = (workspaceRoot: string): string => { const explicitNetwork = process.env['REF_COMPARE_NETWORK']; if (explicitNetwork) { const network = assertSafeDockerNetworkName(explicitNetwork); try { const resolved = execFileSync('docker', ['network', 'inspect', '--format', '{{.Name}}', network], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim(); if (resolved !== network) { throw new Error('network identity mismatch'); } return network; } catch { throw new Error('REF_COMPARE_NETWORK does not identify an available Docker network.'); } } const composeDirectory = path.join(workspaceRoot, 'docker_compose_files/reference'); try { const phpContainerId = execFileSync('docker', ['compose', 'ps', '-q', 'php'], { cwd: composeDirectory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim(); if (!phpContainerId || !/^[a-f0-9]+$/u.test(phpContainerId)) { throw new Error('reference php container is unavailable'); } const networks = execFileSync( 'docker', [ 'inspect', '--format', '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}', phpContainerId, ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], } ) .split(/\r?\n/u) .map((entry) => entry.trim()) .filter(Boolean); if (networks.length !== 1) { throw new Error('reference php container must have exactly one discoverable network'); } return assertSafeDockerNetworkName(networks[0]!); } catch { throw new Error( 'Unable to discover the official reference Compose network. Start that stack or set REF_COMPARE_NETWORK explicitly.' ); } }; const runReferenceSourceScript = (options: { workspaceRoot: string; compareSourceRoot: string; script: string; args?: string[]; input?: string; maxBuffer?: number; }): string => { const resolvedCompareRoot = path.resolve(options.compareSourceRoot); const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-compare-')); fs.cpSync(resolvedCompareRoot, runtimeRoot, { recursive: true, filter: (source) => referenceRuntimeCopyFilter(resolvedCompareRoot, source), }); fs.mkdirSync(path.join(runtimeRoot, 'd_log')); try { const network = resolveReferenceDockerNetwork(options.workspaceRoot); try { return execFileSync( 'docker', [ 'run', '--rm', '-i', '--network', network, '-v', `${runtimeRoot}:/var/www/html`, '-v', `${path.join(options.workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`, '-v', `${path.join(options.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/${options.script}`, ...(options.args ?? []), ], { input: options.input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], ...(options.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }), } ); } catch (error) { const failure = error as { status?: number | null; stderr?: string | Buffer }; const stderr = String(failure.stderr ?? '') .replace(/\s+/gu, ' ') .trim() .slice(0, 500); // Intentionally omit the raw child-process error as the cause: it // retains prior JSONL stdout and can expose a huge fixture corpus. // eslint-disable-next-line preserve-caught-error throw new Error( `reference comparison script failed (exit ${String(failure.status ?? 'unknown')})${stderr ? `: ${stderr}` : ''}` ); } } finally { fs.rmSync(runtimeRoot, { recursive: true, force: true }); } }; const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => { assertFixtureGeneralCityContract(fixtureJson); 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'], } ); const reference = JSON.parse(stdout) as ReferenceTrace; assertReferenceFixtureIdentity(reference, fixtureJson); return reference; } const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT; if (compareSourceRoot) { const stdout = runReferenceSourceScript({ workspaceRoot, compareSourceRoot, script: 'hwe/compare/battle_trace.php', args: ['-'], input: fixtureJson, }); const reference = JSON.parse(stdout) as ReferenceTrace; assertReferenceFixtureIdentity(reference, fixtureJson); return reference; } const stdout = execFileSync( 'docker', ['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/battle_trace.php', '-'], { cwd: path.join(workspaceRoot, 'docker_compose_files/reference'), input: fixtureJson, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], } ); const reference = JSON.parse(stdout) as ReferenceTrace; assertReferenceFixtureIdentity(reference, fixtureJson); return reference; }; interface BattleReferenceManifest { schemaVersion: 1; fixtureCount: number; fixtureJsonlSha256: string; traceCount: number; traceJsonlSha256: string; } const normalizeJsonlForManifest = (lines: string[]): string => `${lines.map((line) => line.trim()).join('\n')}\n`; const readBoundPrecomputedTraces = (tracePath: string, fixtureLines: string[]): ReferenceTrace[] => { const resolvedTracePath = path.resolve(tracePath); const rawTraceJsonl = fs.readFileSync(resolvedTracePath, 'utf8'); const traceLines = rawTraceJsonl.split(/\r?\n/u).filter(Boolean); const manifestPath = path.resolve( process.env['BATTLE_REFERENCE_MANIFEST_PATH'] ?? `${resolvedTracePath}.manifest.json` ); if (!fs.existsSync(manifestPath)) { throw new Error( 'BATTLE_REFERENCE_TRACE_PATH requires BATTLE_REFERENCE_MANIFEST_PATH or a sibling .manifest.json file.' ); } const manifest = readJson(manifestPath); if (manifest.schemaVersion !== 1) { throw new Error('Unsupported battle reference manifest schemaVersion.'); } if (traceLines.length !== fixtureLines.length) { throw new Error(`precomputed ref corpus has ${traceLines.length} traces for ${fixtureLines.length} fixtures`); } const expectedFixtureJsonl = normalizeJsonlForManifest(fixtureLines); const checks: Array<[string, unknown, unknown]> = [ ['fixtureCount', manifest.fixtureCount, fixtureLines.length], ['traceCount', manifest.traceCount, traceLines.length], ['fixtureJsonlSha256', manifest.fixtureJsonlSha256, sha256(expectedFixtureJsonl)], ['traceJsonlSha256', manifest.traceJsonlSha256, sha256(rawTraceJsonl)], ]; for (const [label, actual, expected] of checks) { if (actual !== expected) { throw new Error(`battle reference manifest ${label} mismatch`); } } const traces = traceLines.map((line) => JSON.parse(line) as ReferenceTrace); traces.forEach((trace, index) => assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `trace[${index}]`)); return traces; }; const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]): ReferenceTrace[] => { fixtureLines.forEach((line, index) => assertFixtureGeneralCityContract(line, `fixture[${index}]`)); const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH; if (precomputedTracePath) { return readBoundPrecomputedTraces(precomputedTracePath, fixtureLines); } 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, } ); const traces = 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`); } traces.forEach((trace, index) => assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `container trace[${index}]`) ); return traces; } 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 stdout = runReferenceSourceScript({ workspaceRoot, compareSourceRoot, script: 'hwe/compare/battle_trace.php', args: ['--jsonl'], input: normalizeJsonlForManifest(fixtureLines), maxBuffer: 512 * 1024 * 1024, }); const traces = 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`); } traces.forEach((trace, index) => assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `source trace[${index}]`) ); return traces; }; const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record => { const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT; if (compareSourceRoot) { const stdout = runReferenceSourceScript({ workspaceRoot, compareSourceRoot, script: 'hwe/compare/item_catalog.php', input: JSON.stringify(itemKeys), }); return JSON.parse(stdout) as Record; } const stdout = execFileSync( 'docker', ['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/item_catalog.php'], { cwd: path.join(workspaceRoot, 'docker_compose_files/reference'), input: JSON.stringify(itemKeys), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], } ); return JSON.parse(stdout) as Record; }; const runReferenceTraitCatalog = (workspaceRoot: string): ReferenceTraitCatalog => { const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT; if (compareSourceRoot) { const stdout = runReferenceSourceScript({ workspaceRoot, compareSourceRoot, script: 'hwe/compare/trait_catalog.php', }); return JSON.parse(stdout) as ReferenceTraitCatalog; } const stdout = execFileSync( 'docker', ['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/trait_catalog.php'], { cwd: path.join(workspaceRoot, 'docker_compose_files/reference'), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], } ); return JSON.parse(stdout) as ReferenceTraitCatalog; }; const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => { expect(typeof actual, `${label}: actual type`).toBe('number'); expect(typeof expected, `${label}: reference type`).toBe('number'); const actualNumber = actual as number; const expectedNumber = expected as number; expect(Number.isFinite(actualNumber), `${label}: actual must be finite`).toBe(true); expect(Number.isFinite(expectedNumber), `${label}: reference must be finite`).toBe(true); if (Number.isSafeInteger(actualNumber) && Number.isSafeInteger(expectedNumber)) { expect(actualNumber, `${label}: integer battle parity`).toBe(expectedNumber); return; } const tolerance = Math.max( Number.EPSILON * Math.max(1, Math.abs(expectedNumber)) * 16, Math.abs(expectedNumber) * 1e-12, 1e-12 ); expect( Math.abs(actualNumber - expectedNumber), `${label}: core=${String(actualNumber)}, ref=${String(expectedNumber)}, tolerance=${tolerance}` ).toBeLessThanOrEqual(tolerance); }; const isCanonicalEmptyMapPath = (label: string): boolean => label.endsWith('.activatedSkills') || label.endsWith('.details'); const normalizeCanonicalEmptyMap = (value: unknown, label: string): unknown => { if (isCanonicalEmptyMapPath(label) && Array.isArray(value) && value.length === 0) { return {}; } return value; }; const assertCanonicalValue = (rawActual: unknown, rawExpected: unknown, label: string): void => { const actual = normalizeCanonicalEmptyMap(rawActual, label); const expected = normalizeCanonicalEmptyMap(rawExpected, label); if (typeof actual === 'number' || typeof expected === 'number') { expectNearlyEqual(actual, expected, label); return; } if (Array.isArray(actual) || Array.isArray(expected)) { expect(Array.isArray(actual), `${label}: core array type`).toBe(true); expect(Array.isArray(expected), `${label}: ref array type`).toBe(true); const actualArray = actual as unknown[]; const expectedArray = expected as unknown[]; expect(actualArray.length, `${label}: array length`).toBe(expectedArray.length); for (let index = 0; index < expectedArray.length; index += 1) { assertCanonicalValue(actualArray[index], expectedArray[index], `${label}[${index}]`); } return; } if (isRecord(actual) || isRecord(expected)) { expect(isRecord(actual), `${label}: core object type`).toBe(true); expect(isRecord(expected), `${label}: ref object type`).toBe(true); const actualObject = actual as Record; const expectedObject = expected as Record; const actualKeys = Object.keys(actualObject) .filter((key) => actualObject[key] !== undefined) .sort(); const expectedKeys = Object.keys(expectedObject) .filter((key) => expectedObject[key] !== undefined) .sort(); expect(actualKeys, `${label}: object keys`).toEqual(expectedKeys); for (const key of expectedKeys) { assertCanonicalValue(actualObject[key], expectedObject[key], `${label}.${key}`); } return; } expect(actual, label).toBe(expected); }; const buildCapturedLogBuckets = ( capture: CapturedCoreLogger | null | undefined, year: number, month: number ): ReferenceLogBuckets => { const buckets: ReferenceLogBuckets = { generalHistoryLog: [], generalActionLog: [], generalBattleResultLog: [], generalBattleDetailLog: [], nationalHistoryLog: [], globalHistoryLog: [], globalActionLog: [], }; for (const entry of capture?.entries ?? []) { const text = formatLogText(entry.text, entry.format ?? LogFormat.RAWTEXT, year, month); if (entry.scope === LogScope.GENERAL) { switch (entry.category) { case LogCategory.HISTORY: buckets.generalHistoryLog.push(text); break; case LogCategory.ACTION: buckets.generalActionLog.push(text); break; case LogCategory.BATTLE_BRIEF: buckets.generalBattleResultLog.push(text); break; case LogCategory.BATTLE_DETAIL: buckets.generalBattleDetailLog.push(text); break; default: break; } } else if (entry.scope === LogScope.NATION && entry.category === LogCategory.HISTORY) { buckets.nationalHistoryLog.push(text); } else if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY) { buckets.globalHistoryLog.push(text); } else if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY) { buckets.globalActionLog.push(text); } } return buckets; }; const assertAllLogBucketsParity = ( capture: CoreLogCapture, reference: ReferenceTrace, fixture: BattleSimRequestPayload, label: string ): void => { assertCanonicalValue( buildCapturedLogBuckets(capture.byGeneralId.get(fixture.attackerGeneral.no), fixture.year, fixture.month), reference.logs.attacker, `${label}.logs.attacker` ); for (const defender of fixture.defenderGenerals) { assertCanonicalValue( buildCapturedLogBuckets(capture.byGeneralId.get(defender.no), fixture.year, fixture.month), reference.logs.defenders[String(defender.no)] ?? { generalHistoryLog: [], generalActionLog: [], generalBattleResultLog: [], generalBattleDetailLog: [], nationalHistoryLog: [], globalHistoryLog: [], globalActionLog: [], }, `${label}.logs.defenders.${defender.no}` ); } assertCanonicalValue( buildCapturedLogBuckets(capture.city, fixture.year, fixture.month), reference.logs.city, `${label}.logs.city` ); }; const normalizeRandomArguments = (value: Record): Record => Array.isArray(value) && value.length === 0 ? {} : value; 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, })) ?? []; const normalizedReferenceRng = reference.rng.map(({ seq, operation, arguments: args, result }) => ({ seq, operation, arguments: normalizeRandomArguments(args), result, })); assertCanonicalValue(normalizedCoreRng, normalizedReferenceRng, 'rng'); assertCanonicalValue(coreRng?.boolCalls ?? [], reference.boolRng, 'boolRng'); }; const assertTraceParity = ( coreEvents: WarBattleTraceEvent[], reference: ReferenceTrace, coreRng: TracingRng | null, coreOutcome: WarBattleOutcome | null ): void => { const defenderOrderEvent = coreEvents[0]?.event === 'defender_order' ? coreEvents[0] : null; const comparableCoreEvents = (defenderOrderEvent ? coreEvents.slice(1) : coreEvents).map((event, seq) => ({ ...event, // Core emits one comparison-only defender_order event before the Ref // processWar_NG sequence. Renumber only the canonical shared sequence. seq, })); 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); assertCanonicalValue(comparableCoreEvents, reference.events, 'events'); assertFinalOutcomeParity(coreOutcome, coreEvents, reference); }; const outcomeMetaNumber = (general: WarBattleOutcome['attacker'], key: string): number => { const value = general.meta[key]; return typeof value === 'number' ? value : 0; }; const buildOutcomeGeneralSnapshot = ( transient: WarBattleTraceUnitSnapshot, general: WarBattleOutcome['attacker'], report: WarBattleOutcome['reports'][number], activatedSkills: Record ): WarBattleTraceUnitSnapshot => ({ ...transient, kind: 'general', id: general.id, name: general.name, isAttacker: report.isAttacker, crewTypeId: general.crewTypeId, phase: report.phase ?? transient.phase, hp: general.crew, killed: report.killed, dead: report.dead, activatedSkills, general: { crew: general.crew, rice: general.rice, train: general.train, atmos: general.atmos, injury: general.injury, experience: general.experience, dedication: general.dedication, dex1: outcomeMetaNumber(general, 'dex1'), dex2: outcomeMetaNumber(general, 'dex2'), dex3: outcomeMetaNumber(general, 'dex3'), dex4: outcomeMetaNumber(general, 'dex4'), dex5: outcomeMetaNumber(general, 'dex5'), }, }); const assertFinalOutcomeParity = ( coreOutcome: WarBattleOutcome | null, coreEvents: WarBattleTraceEvent[], reference: ReferenceTrace ): void => { expect(coreOutcome, 'comparison onBattleResolved callback').not.toBeNull(); if (!coreOutcome) { return; } const finalEvent = coreEvents.at(-1); expect(finalEvent?.event, 'final battle trace event').toBe('battle_end'); if (!finalEvent) { return; } const attackerReport = coreOutcome.reports.find( (report) => report.type === 'general' && report.id === coreOutcome.attacker.id && report.isAttacker ); const cityReport = coreOutcome.reports.find( (report) => report.type === 'city' && report.id === coreOutcome.defenderCity.id ); expect(attackerReport, 'final attacker report').toBeDefined(); expect(cityReport, 'final city report').toBeDefined(); if (!attackerReport || !cityReport) { return; } const latestDefenderSnapshots = new Map(); for (const event of coreEvents) { if (event.defender?.kind === 'general') { latestDefenderSnapshots.set(event.defender.id, event.defender); } } const metrics = coreOutcome.metrics; const coreAttacker = buildOutcomeGeneralSnapshot( finalEvent.attacker, coreOutcome.attacker, attackerReport, metrics?.attackerActivatedSkills ?? {} ); const coreCity: WarBattleTraceUnitSnapshot = { ...finalEvent.city, kind: 'city', id: coreOutcome.defenderCity.id, name: coreOutcome.defenderCity.name, isAttacker: cityReport.isAttacker, phase: cityReport.phase ?? finalEvent.city.phase, killed: cityReport.killed, dead: cityReport.dead, cityState: { defence: coreOutcome.defenderCity.defence, wall: coreOutcome.defenderCity.wall, population: coreOutcome.defenderCity.population, }, }; const coreFinishedDefenders = reference.finishedDefenders.map((expectedSnapshot) => { if (expectedSnapshot.kind === 'city') { return coreCity; } const defenderIndex = coreOutcome.defenders.findIndex((general) => general.id === expectedSnapshot.id); expect(defenderIndex, `final defender ${expectedSnapshot.id} exists`).toBeGreaterThanOrEqual(0); const general = coreOutcome.defenders[defenderIndex]; const orderedDefenderReports = coreOutcome.reports.filter( (candidate) => candidate.type === 'general' && !candidate.isAttacker ); const metricIndex = orderedDefenderReports.findIndex((candidate) => candidate.id === expectedSnapshot.id); const report = metricIndex >= 0 ? orderedDefenderReports[metricIndex] : undefined; const transient = latestDefenderSnapshots.get(expectedSnapshot.id); expect(general, `final defender ${expectedSnapshot.id} state`).toBeDefined(); expect(report, `final defender ${expectedSnapshot.id} report`).toBeDefined(); expect(transient, `final defender ${expectedSnapshot.id} transient snapshot`).toBeDefined(); if (!general || !report || !transient) { return expectedSnapshot; } return buildOutcomeGeneralSnapshot( transient, general, report, metrics?.defenderActivatedSkills[metricIndex] ?? {} ); }); assertCanonicalValue( { conquered: coreOutcome.conquered, attacker: coreAttacker, city: coreCity, finishedDefenders: coreFinishedDefenders, }, { conquered: reference.conquered, attacker: reference.attacker, city: reference.city, finishedDefenders: reference.finishedDefenders, }, 'finalOutcome' ); }; const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; const workspaceRoot = configuredWorkspaceRoot ?? findWorkspaceRoot(process.cwd()); if (process.env.TURN_DIFFERENTIAL_REFERENCE === '1' && !workspaceRoot) { throw new Error( 'TURN_DIFFERENTIAL_REFERENCE=1 requires TURN_DIFFERENTIAL_WORKSPACE_ROOT when running outside the workspace tree.' ); } const describeWithReference = workspaceRoot ? describe : describe.skip; const battleCorpusPath = process.env.BATTLE_CORPUS_PATH; const itWithBattleCorpus = battleCorpusPath ? it : it.skip; describeWithReference('ref ↔ core2026 battle differential', () => { it('rejects battle fixtures whose general current-city contract is missing or inconsistent', () => { const fixture = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); const missingCity = structuredClone(fixture) as BattleSimRequestPayload & { attackerGeneral: BattleSimGeneralPayload & { city?: number }; }; delete missingCity.attackerGeneral.city; expect(() => assertFixtureGeneralCityContract(JSON.stringify(missingCity), 'missing-city')).toThrow( 'attackerGeneral.city must be an explicit positive integer' ); const wrongDefenderCity = structuredClone(fixture); wrongDefenderCity.defenderGenerals[0]!.city = fixture.attackerCity.city; expect(() => assertFixtureGeneralCityContract(JSON.stringify(wrongDefenderCity), 'wrong-city')).toThrow( 'defenderGeneral[0].city=1 must equal current city 2' ); }); itWithBattleCorpus( 'replays a captured battle corpus with matching trace, RNG, full outcome, and all log buckets [conditional: BATTLE_CORPUS_PATH]', { 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[] = []; const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | 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), loggerFactory: coreLogs.loggerFactory, onBattleResolved: (outcome) => { coreOutcome = outcome; }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); return coreRng.createRandUtil(); }, } ); try { assertTraceParity(coreEvents, reference, coreRng, coreOutcome); } catch (error) { recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error)); } try { assertAllLogBucketsParity(coreLogs, reference, fixture, `fixture[${index}]`); } catch (error) { recordFailure('logs', index, fixture, error instanceof Error ? error.message : String(error)); } 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') ); 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 cases: Array<{ name: string; effect: string; directCity?: boolean; multipleDefenders?: boolean; }> = [ { name: 'unlimited-general', effect: 'event_UnlimitedDefenceThresholdChange' }, { name: 'strong-general', effect: 'event_StrongAttacker' }, { name: 'more-general', effect: 'event_MoreEffect' }, { name: 'strong-city', effect: 'event_StrongAttacker', directCity: true }, { name: 'more-city', effect: 'event_MoreEffect', directCity: true }, { name: 'strong-fresh-defender', effect: 'event_StrongAttacker', multipleDefenders: true }, ]; for (const entry of cases) { const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); base.seed = `battle-differential-scenario-${entry.name}`; base.scenarioEffect = entry.effect; base.attackerGeneral.crew = entry.multipleDefenders ? 5000 : 3000; if (entry.directCity) { base.defenderGenerals = []; } if (entry.multipleDefenders) { base.defenderGenerals[0]!.crew = 1; } if (entry.multipleDefenders) { base.defenderGenerals.push({ ...base.defenderGenerals[0]!, no: 3, name: '새 수비자', crew: 1200, leadership: 1, strength: 1, intel: 1, }); } const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; const coreResult = processBattleSimJob( { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.startYear }, scenarioEffect: entry.effect, }, { trace: (event) => coreEvents.push(event), onBattleResolved: (outcome) => { coreOutcome = outcome; }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); return coreRng.createRandUtil(); }, } ); try { const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); assertTraceParity(coreEvents, reference, coreRng, coreOutcome); const opponentSwitches = coreEvents.filter((event) => event.event === 'opponent_switched'); if (entry.directCity) { expect( coreEvents.some( (event) => event.event === 'opponent_initialized' && event.defender?.kind === 'city' ), `${entry.name}: must execute direct city combat` ).toBe(true); } if (entry.multipleDefenders) { expect( opponentSwitches.some((event) => event.defender?.kind === 'general' && event.defender.id === 3), `${entry.name}: must transition to the fresh defender` ).toBe(true); const switchedIndex = coreEvents.findIndex( (event) => event.event === 'opponent_switched' && event.defender?.kind === 'general' && event.defender.id === 3 ); const maxPhaseBeforeAdvance = coreEvents[switchedIndex]!.attacker.maxPhase; expect( coreEvents .slice(switchedIndex + 1) .some( (event) => event.event === 'phase_triggered' && event.attacker.maxPhase === maxPhaseBeforeAdvance + 1 ), `${entry.name}: advance trigger must extend attacker phases after the switch` ).toBe(true); expect(reference.logs.attacker.generalBattleDetailLog).toContain( '●적군의 전멸에 진격이 이어집니다!' ); expect(reference.logs.defenders['3']?.generalBattleDetailLog).toContain( '●아군의 전멸에 상대의 진격이 이어집니다!' ); expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain( '적군의 전멸에 진격이 이어집니다!' ); } } catch (error) { throw new Error(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`, { cause: error, }); } } }); it('matches every legacy trait name and description', async () => { const reference = runReferenceTraitCatalog(workspaceRoot!); const [nation, domestic, war, personality] = await Promise.all([ loadNationTraitModules([...NATION_TRAIT_KEYS]), loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]), loadWarTraitModules([...WAR_TRAIT_KEYS]), loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]), ]); const groups = { nation, domestic, war, personality }; const failures: string[] = []; for (const [kind, modules] of Object.entries(groups) as Array< [keyof ReferenceTraitCatalog, Array<{ key: string; name: string; info: string }>] >) { expect(modules.map((module) => module.key).sort()).toEqual(Object.keys(reference[kind]).sort()); for (const module of modules) { const actual = { name: module.name, info: module.info }; if (JSON.stringify(actual) !== JSON.stringify(reference[kind][module.key])) { failures.push( `${kind}/${module.key}: core=${JSON.stringify(actual)} ref=${JSON.stringify(reference[kind][module.key])}` ); } } } expect(failures, failures.join('\n')).toEqual([]); }); it('matches every event-domestic, war, personality, and nation trait in battle', { 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 cases = [ ...EVENT_DOMESTIC_TRAIT_KEYS.map((key) => ({ kind: 'eventDomestic' as const, key, })), ...WAR_TRAIT_KEYS.map((key) => ({ kind: 'war' as const, key })), ...PERSONALITY_TRAIT_KEYS.map((key) => ({ kind: 'personality' as const, key })), ...NATION_TRAIT_KEYS.map((key) => ({ kind: 'nation' as const, key })), { kind: 'dualSlot' as const, key: 'che_event_무쌍+che_무쌍', special: 'che_event_무쌍', special2: 'che_무쌍', }, { kind: 'dualSlot' as const, key: 'che_event_견고+che_견고', special: 'che_event_견고', special2: 'che_견고', }, ]; for (const entry of cases) { const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); base.seed = `battle-differential-trait-${entry.kind}-${entry.key}`; base.attackerGeneral.crew = 5000; base.attackerGeneral.leadership = 90; base.attackerGeneral.strength = 85; base.attackerGeneral.intel = 80; base.attackerGeneral.special = entry.kind === 'dualSlot' ? entry.special : entry.kind === 'eventDomestic' ? entry.key : 'None'; base.attackerGeneral.special2 = entry.kind === 'dualSlot' ? entry.special2 : entry.kind === 'war' ? entry.key : 'None'; base.attackerGeneral.personal = entry.kind === 'personality' ? entry.key : 'None'; if (entry.kind === 'nation') { base.attackerNation.type = entry.key; } const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob( { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.startYear }, }, { trace: (event) => coreEvents.push(event), onBattleResolved: (outcome) => { coreOutcome = outcome; }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); return coreRng.createRandUtil(); }, } ); try { assertTraceParity( coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng, coreOutcome ); } catch (error) { throw new Error( `${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`, { cause: error, } ); } } }); it('loads every scenario item with the scenario slot', async () => { const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario'); const itemSlots = new Map(); for (const filename of fs.readdirSync(scenarioDir).filter((entry) => entry.endsWith('.json'))) { const scenario = readJson<{ const?: { allItems?: Partial>>; }; }>(path.join(scenarioDir, filename)); for (const slot of ['horse', 'weapon', 'book', 'item'] as const) { for (const itemKey of Object.keys(scenario.const?.allItems?.[slot] ?? {})) { itemSlots.set(itemKey, slot); } } } const modules = await loadItemModules([...ITEM_KEYS]); const reference = runReferenceItemCatalog(workspaceRoot!, [...ITEM_KEYS]); expect(modules).toHaveLength(145); const failures: string[] = []; for (const module of modules) { const actual = { rawName: module.rawName, name: module.name, info: module.info, cost: module.cost, buyable: module.buyable, consumable: module.consumable, reqSecu: module.reqSecu, }; if ( module.slot !== itemSlots.get(module.key) || module.unique !== !reference[module.key]?.buyable || JSON.stringify(actual) !== JSON.stringify(reference[module.key]) ) { failures.push( `${module.key}: slot=${module.slot}/${String(itemSlots.get(module.key))} unique=${String(module.unique)}/${String(!reference[module.key]?.buyable)} core=${JSON.stringify(actual)} ref=${JSON.stringify(reference[module.key])}` ); } } expect(failures, failures.join('\n')).toEqual([]); }); it('matches the legacy non-stacking rule for the 무쌍 trait item', () => { const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); base.seed = 'battle-differential-duplicate-musang'; base.attackerGeneral.personal = 'None'; base.attackerGeneral.special2 = 'che_무쌍'; base.attackerGeneral.item = 'event_전투특기_무쌍'; base.attackerGeneral.crew = 5000; base.attackerGeneral.leadership = 90; base.attackerGeneral.strength = 90; base.attackerGeneral.intel = 90; base.attackerGeneral.dex1 = 12000; 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 coreEvents: WarBattleTraceEvent[] = []; const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob( { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.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 = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); assertTraceParity(coreEvents, reference, coreRng, coreOutcome); assertAllLogBucketsParity(coreLogs, reference, base, 'trait-item.non-stacking-musang'); const runFirstPhasePower = (fixture: BattleSimRequestPayload & { startYear: number }): number => { const events: WarBattleTraceEvent[] = []; processBattleSimJob( { ...fixture, unitSet, config, time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear }, }, { trace: (event) => events.push(event) } ); const firstPhase = events.find((event) => event.event === 'phase_power'); expect(firstPhase, '무쌍 first phase power').toBeDefined(); return firstPhase!.attacker.rawWarPower; }; const combinedPower = coreEvents.find((event) => event.event === 'phase_power')!.attacker.rawWarPower; const traitOnly = structuredClone(base); traitOnly.attackerGeneral.item = 'None'; const itemOnly = structuredClone(base); itemOnly.attackerGeneral.special2 = 'None'; const control = structuredClone(itemOnly); control.attackerGeneral.item = 'None'; expect(combinedPower, 'duplicate 무쌍 does not stack over trait').toBe(runFirstPhasePower(traitOnly)); expect(combinedPower, 'duplicate 무쌍 does not stack over item').toBe(runFirstPhasePower(itemOnly)); expect(combinedPower, '무쌍 has a real battle effect').not.toBe(runFirstPhasePower(control)); }); it('matches 척사 items against region-restricted troops', () => { 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 }, }; for (const itemKey of ['che_척사_오악진형도', 'event_전투특기_척사']) { const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); base.seed = `battle-differential-region-item-${itemKey}`; base.attackerGeneral.personal = 'None'; base.attackerGeneral.special2 = 'None'; base.attackerGeneral.item = itemKey; base.attackerGeneral.crew = 5000; base.defenderGenerals[0]!.crewtype = 1101; const coreEvents: WarBattleTraceEvent[] = []; const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob( { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.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 = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); assertTraceParity(coreEvents, reference, coreRng, coreOutcome); assertAllLogBucketsParity(coreLogs, reference, base, `item.${itemKey}.region-opponent`); const control = structuredClone(base); control.attackerGeneral.item = 'None'; const controlEvents: WarBattleTraceEvent[] = []; processBattleSimJob( { ...control, unitSet, config, time: { year: control.year, month: control.month, startYear: control.startYear }, }, { trace: (event) => controlEvents.push(event) } ); const itemPower = coreEvents.find((event) => event.event === 'phase_power')?.attacker.rawWarPower; const controlPower = controlEvents.find((event) => event.event === 'phase_power')?.attacker.rawWarPower; expect(itemPower, `${itemKey}: region troop effect is observed`).not.toBe(controlPower); } }); it('matches the complete canonical event, RNG, state, and logger snapshots', () => { const fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json'); const fixtureJson = fs.readFileSync(fixturePath, 'utf8'); const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number }; const unitSet = readJson( path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') ); const castleCrewTypeId = 1000; const config: WarEngineConfig = { armPerPhase: 500, maxTrainByCommand: 100, maxAtmosByCommand: 100, maxTrainByWar: 110, maxAtmosByWar: 150, castleCrewTypeId, armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: unitSet.crewTypes?.find((crewType) => crewType.id === castleCrewTypeId)?.armType ?? 0, }, }; const payload: BattleSimJobPayload = { ...request, unitSet, config, time: { year: request.year, month: request.month, startYear: request.startYear }, }; const coreEvents: WarBattleTraceEvent[] = []; const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; const coreResult = processBattleSimJob(payload, { 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 = runReferenceTrace(workspaceRoot!, fixtureJson); expect(coreResult.result).toBe(true); assertTraceParity(coreEvents, reference, coreRng, coreOutcome); assertAllLogBucketsParity(coreLogs, reference, request, 'basic-infantry'); }); it('matches officer levels 1-4 in assigned and off-city battles on both sides', () => { 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 cases: Array<{ role: 'attacker' | 'defender'; level: number; assigned: boolean; fixture: BattleSimRequestPayload & { startYear: number }; }> = []; for (const role of ['attacker', 'defender'] as const) { for (const level of [1, 2, 3, 4]) { for (const assigned of [true, false]) { const fixture = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); fixture.seed = `battle-differential-officer-${role}-${level}-${assigned ? 'assigned' : 'off-city'}`; const general = role === 'attacker' ? fixture.attackerGeneral : fixture.defenderGenerals[0]!; const counterpart = role === 'attacker' ? fixture.defenderGenerals[0]! : fixture.attackerGeneral; const currentCity = role === 'attacker' ? fixture.attackerCity.city : fixture.defenderCity.city; const counterpartCity = role === 'attacker' ? fixture.defenderCity.city : fixture.attackerCity.city; general.officer_level = level; general.officer_city = assigned ? currentCity : currentCity + 1000; // Keep the opposite unit neutral so the subject officer's attack/defence // multiplier is observable without the counterpart's level-3 5% modifier. counterpart.officer_level = 1; counterpart.officer_city = counterpartCity; cases.push({ role, level, assigned, fixture }); } } } const fixtureLines = cases.map(({ fixture }) => JSON.stringify(fixture)); const references = runReferenceTraceBatch(workspaceRoot!, fixtureLines); const officerSignatures = new Map(); cases.forEach(({ role, level, assigned, 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 = `officer.${role}.level${level}.${assigned ? 'assigned' : 'off-city'}`; assertTraceParity(coreEvents, reference, coreRng, coreOutcome); assertAllLogBucketsParity(coreLogs, reference, fixture, label); const phasePower = coreEvents.find((event) => event.event === 'phase_power'); const snapshot = role === 'attacker' ? phasePower?.attacker : phasePower?.defender; const counterpartSnapshot = role === 'attacker' ? phasePower?.defender : phasePower?.attacker; expect(snapshot?.kind, `${label}: participating general`).toBe('general'); expect(counterpartSnapshot?.kind, `${label}: counterpart general`).toBe('general'); officerSignatures.set( `${role}-${level}-${assigned}`, JSON.stringify({ subjectRawWarPower: snapshot!.rawWarPower, counterpartWarPowerMultiplier: counterpartSnapshot!.warPowerMultiplier, }) ); }); for (const role of ['attacker', 'defender'] as const) { expect(officerSignatures.get(`${role}-1-true`), `${role}: level 1 ignores assignment`).toBe( officerSignatures.get(`${role}-1-false`) ); for (const level of [2, 3, 4]) { expect( officerSignatures.get(`${role}-${level}-false`), `${role}: off-city level ${level} falls back` ).toBe(officerSignatures.get(`${role}-1-true`)); expect( officerSignatures.get(`${role}-${level}-true`), `${role}: assigned level ${level} keeps the officer battle signature` ).not.toBe(officerSignatures.get(`${role}-${level}-false`)); } } }); it('matches every distinct CHE crew battle signature on attacker and defender paths', { timeout: 180_000 }, () => { const unitSet = readJson( path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') ); const crewTypes = unitSet.crewTypes ?? []; const signatures = crewTypes.map((crewType) => JSON.stringify({ armType: crewType.armType, attack: crewType.attack, defence: crewType.defence, speed: crewType.speed, avoid: crewType.avoid, magicCoef: crewType.magicCoef, rice: crewType.rice, attackCoef: crewType.attackCoef, defenceCoef: crewType.defenceCoef, iActionList: crewType.iActionList, initSkillTrigger: crewType.initSkillTrigger, phaseSkillTrigger: crewType.phaseSkillTrigger, }) ); expect(new Set(signatures).size, 'unitset_che distinct battle signatures').toBe(crewTypes.length); 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 cases: Array<{ role: 'attacker' | 'defender'; crewTypeId: number; fixture: BattleSimRequestPayload & { startYear: number }; }> = []; const crewFilter = process.env.CREW_PARITY_FILTER; const crewRoleFilter = process.env.CREW_PARITY_ROLE; for (const crewType of crewTypes.filter((entry) => entry.id !== config.castleCrewTypeId)) { if (crewFilter && String(crewType.id) !== crewFilter) { continue; } for (const role of ['attacker', 'defender'] as const) { if (crewRoleFilter && role !== crewRoleFilter) { continue; } // In the official assertion-enabled Ref image, attacker-side // 정란/벽력거 routes the castle first and then the castle's // general-only phase trigger aborts. Their distinct phase skill // remains covered on the defender path; the Ref runtime defect is // documented as an explicit remaining boundary. if (role === 'attacker' && (crewType.id === 1500 || crewType.id === 1502)) { continue; } const fixture = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); fixture.seed = `battle-differential-crew-${role}-${crewType.id}`; // Keep every synthetic pairing in general-vs-general combat for the // whole phase budget. City combat is covered separately and the Ref // castle unit intentionally carries a general-only phase assertion. fixture.attackerGeneral.crew = 50000; fixture.attackerGeneral.rice = 1000000; fixture.attackerGeneral.leadership = 90; fixture.attackerGeneral.strength = 90; fixture.attackerGeneral.intel = 90; fixture.defenderGenerals[0]!.crew = 50000; fixture.defenderGenerals[0]!.rice = 1000000; fixture.defenderGenerals[0]!.leadership = 85; fixture.defenderGenerals[0]!.strength = 85; fixture.defenderGenerals[0]!.intel = 85; fixture.defenderCity.def = 400; fixture.defenderCity.wall = 400; fixture.defenderCity.def_max = 400; fixture.defenderCity.wall_max = 400; const general = role === 'attacker' ? fixture.attackerGeneral : fixture.defenderGenerals[0]!; general.crewtype = crewType.id; general.dex1 = 12000; general.dex2 = 12000; general.dex3 = 12000; general.dex4 = 12000; general.dex5 = 12000; cases.push({ role, crewTypeId: crewType.id, fixture }); } } const fixtureLines = cases.map(({ fixture }) => JSON.stringify(fixture)); const references = runReferenceTraceBatch(workspaceRoot!, fixtureLines); cases.forEach(({ role, crewTypeId, 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 = `crew.${role}.${crewTypeId}`; try { assertTraceParity(coreEvents, reference, coreRng, coreOutcome); assertAllLogBucketsParity(coreLogs, reference, fixture, label); } catch (error) { const debug = process.env.CREW_PARITY_DEBUG === '1' ? ` coreEvents=${JSON.stringify(coreEvents.map((event) => [event.seq, event.event, event.attacker.phase, event.defender?.phase, event.defender?.activatedSkills]))} refEvents=${JSON.stringify(reference.events.map((event) => [event.seq, event.event, event.attacker.phase, event.defender?.phase, event.defender?.activatedSkills]))}` : ''; throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}${debug}`, { cause: error, }); } if (role === 'defender' && (crewTypeId === 1500 || crewTypeId === 1502)) { expect( coreEvents.some((event) => (event.defender?.activatedSkills['선제'] ?? 0) > 0), `${label}: 정란/벽력거 선제사격 must activate` ).toBe(true); } if (role === 'defender' && crewTypeId === 1503) { expect( coreEvents.some((event) => (event.defender?.activatedSkills['저지'] ?? 0) > 0), `${label}: 목우 저지 must activate for the fixed seed` ).toBe(true); } }); }); 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') ); base.seed = 'battle-differential-magic-v1'; base.attackerGeneral.crew = 4000; base.attackerGeneral.crewtype = 1400; base.attackerGeneral.leadership = 60; base.attackerGeneral.strength = 30; base.attackerGeneral.intel = 95; base.attackerGeneral.special2 = 'che_신산'; base.attackerGeneral.personal = 'che_재간'; base.attackerGeneral.dex4 = 12000; base.defenderGenerals[0]!.crew = 4000; base.defenderGenerals[0]!.crewtype = 1100; base.defenderGenerals[0]!.leadership = 65; base.defenderGenerals[0]!.strength = 75; base.defenderGenerals[0]!.intel = 35; base.defenderGenerals[0]!.special2 = 'che_견고'; base.defenderGenerals[0]!.personal = 'che_유지'; const fixtureJson = JSON.stringify(base); 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 payload: BattleSimJobPayload = { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.startYear }, }; const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; const result = processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), onBattleResolved: (outcome) => { coreOutcome = outcome; }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, fixtureJson); expect(result.result).toBe(true); expect( reference.events.some((event) => Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill)) ) ).toBe(true); assertTraceParity(coreEvents, reference, coreRng, coreOutcome); // 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 fractionalCoreRng.createRandUtil(); }, } ); const fractionalReference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); assertRngParity(fractionalReference, fractionalCoreRng); }); it('matches cavalry, item, inherit-buff, and multiple-defender handling', () => { const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); base.seed = 'battle-differential-cavalry-items-v1'; base.attackerGeneral.crew = 7000; base.attackerGeneral.crewtype = 1300; base.attackerGeneral.leadership = 88; base.attackerGeneral.strength = 92; base.attackerGeneral.intel = 55; base.attackerGeneral.special2 = 'che_기병'; base.attackerGeneral.personal = 'che_패권'; base.attackerGeneral.horse = 'che_명마_07_백상'; base.attackerGeneral.dex3 = 18000; base.attackerGeneral.inheritBuff = { warAvoidRatio: 2, warCriticalRatio: 3, warCriticalRatioOppose: 2, }; const firstDefender = base.defenderGenerals[0]!; firstDefender.crew = 900; firstDefender.crewtype = 1200; firstDefender.leadership = 58; firstDefender.strength = 55; firstDefender.intel = 62; firstDefender.dex2 = 6000; firstDefender.inheritBuff = { warAvoidRatioOppose: 2 }; base.defenderGenerals.push({ ...firstDefender, no: 3, name: '수비자2', crew: 1100, crewtype: 1300, leadership: 64, strength: 72, intel: 48, dex2: 0, dex3: 8000, special2: 'che_기병', personal: 'che_패권', horse: 'che_명마_07_백상', inheritBuff: { warAvoidRatio: 1, warCriticalRatioOppose: 1, }, }); const fixtureJson = JSON.stringify(base); 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 payload: BattleSimJobPayload = { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.startYear }, }; const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; const result = processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), onBattleResolved: (outcome) => { coreOutcome = outcome; }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, fixtureJson); expect(result.result).toBe(true); expect(reference.events.filter((event) => event.event === 'opponent_switched')).toHaveLength(2); assertTraceParity(coreEvents, reference, coreRng, coreOutcome); }); it('matches siege dexterity and castle damage handling', () => { const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); base.seed = 'battle-differential-siege-v1'; base.attackerGeneral.crew = 5000; base.attackerGeneral.crewtype = 1501; base.attackerGeneral.leadership = 90; base.attackerGeneral.strength = 65; base.attackerGeneral.intel = 55; base.attackerGeneral.dex5 = 16000; base.defenderGenerals[0]!.crew = 300; base.defenderGenerals[0]!.crewtype = 1200; base.defenderGenerals[0]!.leadership = 65; base.defenderGenerals[0]!.strength = 72; base.defenderGenerals[0]!.intel = 50; base.defenderGenerals[0]!.dex2 = 10000; const fixtureJson = JSON.stringify(base); 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 payload: BattleSimJobPayload = { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.startYear }, }; const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; const result = processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), onBattleResolved: (outcome) => { coreOutcome = outcome; }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, fixtureJson); expect(result.result).toBe(true); expect(reference.events.some((event) => event.defender?.kind === 'city')).toBe(true); assertTraceParity(coreEvents, reference, coreRng, coreOutcome); }); it('matches the no-defender supply-retreat branch without consuming RNG', () => { const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); base.seed = 'battle-differential-supply-v1'; base.defenderGenerals = []; base.defenderNation.rice = 0; const fixtureJson = JSON.stringify(base); 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 payload: BattleSimJobPayload = { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.startYear }, }; const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; const result = processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), onBattleResolved: (outcome) => { coreOutcome = outcome; }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, fixtureJson); expect(result.result).toBe(true); expect(reference.events.map((event) => event.event)).toEqual(['battle_start', 'supply_retreat', 'battle_end']); assertTraceParity(coreEvents, reference, coreRng, coreOutcome); }); it('matches every scenario item in an attacker battle simulation', { timeout: 180_000 }, () => { const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario'); const itemSlots = new Map(); for (const filename of fs.readdirSync(scenarioDir).filter((entry) => entry.endsWith('.json'))) { const scenario = readJson<{ const?: { allItems?: Partial>>; }; }>(path.join(scenarioDir, filename)); for (const slot of ['horse', 'weapon', 'book', 'item'] as const) { for (const itemKey of Object.keys(scenario.const?.allItems?.[slot] ?? {})) { itemSlots.set(itemKey, slot); } } } expect(itemSlots.size).toBe(145); expect([...ITEM_KEYS].sort()).toEqual([...itemSlots.keys()].sort()); 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 itemFilter = process.env['ITEM_PARITY_FILTER']; for (const [itemKey, slot] of [...itemSlots].sort(([lhs], [rhs]) => lhs.localeCompare(rhs))) { if (itemFilter && itemKey !== itemFilter) { continue; } const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); base.seed = `battle-differential-item-${itemKey}`; base.attackerGeneral.personal = 'None'; base.attackerGeneral.special2 = 'None'; base.attackerGeneral.crew = 5000; base.attackerGeneral.leadership = 90; base.attackerGeneral.strength = 90; base.attackerGeneral.intel = 90; base.attackerGeneral.dex1 = 12000; base.attackerGeneral.dex2 = 12000; base.attackerGeneral.dex3 = 12000; base.attackerGeneral.dex4 = 12000; base.attackerGeneral.dex5 = 12000; base.attackerGeneral[slot] = itemKey; const defender = base.defenderGenerals[0]!; defender.personal = 'None'; defender.special2 = 'None'; defender.crew = 1500; defender.leadership = 75; defender.strength = 75; defender.intel = 75; base.defenderCity.wall = 3000; base.defenderCity.wall_max = 3000; const payload: BattleSimJobPayload = { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.startYear }, }; const coreEvents: WarBattleTraceEvent[] = []; const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob(payload, { 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 = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); try { assertTraceParity(coreEvents, reference, coreRng, coreOutcome); assertAllLogBucketsParity(coreLogs, reference, base, `item.attacker.${itemKey}`); } catch (error) { const debug = process.env['ITEM_PARITY_DEBUG'] === '1' ? `\ncore=${JSON.stringify( coreEvents .map((event, index) => ({ index, event })) .filter( ({ event }) => event.event === 'phase_power' || event.event === 'phase_damage' ) )}\nref=${JSON.stringify( reference.events .map((event, index) => ({ index, event })) .filter( ({ event }) => event.event === 'phase_power' || event.event === 'phase_damage' ) )}` : ''; failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`); } } expect(failures, failures.join('\n')).toEqual([]); }); it('matches every scenario item in a defender battle simulation', { timeout: 180_000 }, () => { const scenarioDir = path.resolve(process.cwd(), '../../resources/scenario'); const itemSlots = new Map(); for (const filename of fs.readdirSync(scenarioDir).filter((entry) => entry.endsWith('.json'))) { const scenario = readJson<{ const?: { allItems?: Partial>>; }; }>(path.join(scenarioDir, filename)); for (const slot of ['horse', 'weapon', 'book', 'item'] as const) { for (const itemKey of Object.keys(scenario.const?.allItems?.[slot] ?? {})) { itemSlots.set(itemKey, slot); } } } 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 itemFilter = process.env['ITEM_PARITY_FILTER']; for (const [itemKey, slot] of [...itemSlots].sort(([lhs], [rhs]) => lhs.localeCompare(rhs))) { if (itemFilter && itemKey !== itemFilter) { continue; } const base = readJson( path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') ); base.seed = `battle-differential-defender-item-${itemKey}`; base.attackerGeneral.personal = 'None'; base.attackerGeneral.special2 = 'None'; base.attackerGeneral.crew = 1500; base.attackerGeneral.leadership = 75; base.attackerGeneral.strength = 75; base.attackerGeneral.intel = 75; const defender = base.defenderGenerals[0]!; defender.personal = 'None'; defender.special2 = 'None'; defender.crew = 5000; defender.leadership = 90; defender.strength = 90; defender.intel = 90; defender.dex1 = 12000; defender.dex2 = 12000; defender.dex3 = 12000; defender.dex4 = 12000; defender.dex5 = 12000; defender[slot] = itemKey; const payload: BattleSimJobPayload = { ...base, unitSet, config, time: { year: base.year, month: base.month, startYear: base.startYear }, }; const coreEvents: WarBattleTraceEvent[] = []; const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob(payload, { 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 = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); try { assertTraceParity(coreEvents, reference, coreRng, coreOutcome); assertAllLogBucketsParity(coreLogs, reference, base, `item.defender.${itemKey}`); } catch (error) { const debug = process.env['ITEM_PARITY_DEBUG'] === '1' ? `\ncore=${JSON.stringify( coreEvents .map((event, index) => ({ index, event })) .filter( ({ event }) => event.event === 'phase_power' || event.event === 'phase_damage' ) )}\nref=${JSON.stringify( reference.events .map((event, index) => ({ index, event })) .filter( ({ event }) => event.event === 'phase_power' || event.event === 'phase_damage' ) )}` : ''; failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`); } } expect(failures, failures.join('\n')).toEqual([]); }); });