feat: port scenario action effects

This commit is contained in:
2026-07-30 19:00:04 +00:00
parent 7f31459385
commit c68d992046
39 changed files with 1131 additions and 95 deletions
@@ -2,6 +2,7 @@ import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import {
GENERAL_TURN_COMMAND_KEYS,
NATION_TURN_COMMAND_KEYS,
normalizeScenarioEffect,
type MapDefinition,
type Nation,
type TurnCommandProfile,
@@ -51,6 +52,7 @@ export interface TurnCommandFixtureRequest {
year?: number;
month?: number;
hiddenSeed?: string;
scenarioEffect?: string | null;
staticEventHandlers?: Record<string, string[]>;
};
isolateWorld?: boolean;
@@ -428,7 +430,13 @@ export const buildCoreTurnCommandWorldInput = (
? { staticEventHandlers: request.setup.world.staticEventHandlers }
: {}),
},
environment: { mapName: map.id, unitSet: unitSet.id },
environment: {
mapName: map.id,
unitSet: unitSet.id,
...(request.setup?.world?.scenarioEffect !== undefined
? { scenarioEffect: normalizeScenarioEffect(request.setup.world.scenarioEffect) }
: {}),
},
},
scenarioMeta: {
title: '턴 명령 차등',
@@ -25,6 +25,20 @@ const withProjectedTraceMeta = (trace: CanonicalTurnCommandTrace): CanonicalTurn
after: withProjectedGeneralMeta(trace.after),
});
const referenceRunnerEnvironment = (workspaceRoot: string, stackDirectory: string): NodeJS.ProcessEnv => {
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
return {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
...(compareSourceRoot
? {
TURN_DIFFERENTIAL_APP_DIR: path.resolve(compareSourceRoot),
TURN_DIFFERENTIAL_RUNTIME_DIR: path.join(workspaceRoot, 'ref/sam'),
}
: {}),
};
};
export const findTurnDifferentialWorkspaceRoot = (start: string): string | null => {
let current = path.resolve(start);
while (true) {
@@ -82,8 +96,7 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath:
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
...referenceRunnerEnvironment(workspaceRoot, stackDirectory),
},
});
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
@@ -101,8 +114,7 @@ export const runReferenceTurnCommandTraceRequest = (
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
...referenceRunnerEnvironment(workspaceRoot, stackDirectory),
},
});
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
@@ -1,5 +1,6 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
@@ -28,6 +29,21 @@ interface ReferenceTrace {
conquered: boolean;
events: WarBattleTraceEvent[];
rng: RandomCall[];
logs: {
attacker: ReferenceLogBuckets;
defenders: Record<string, ReferenceLogBuckets>;
city: ReferenceLogBuckets;
};
}
interface ReferenceLogBuckets {
generalHistoryLog: string[];
generalActionLog: string[];
generalBattleResultLog: string[];
generalBattleDetailLog: string[];
nationalHistoryLog: string[];
globalHistoryLog: string[];
globalActionLog: string[];
}
interface RandomCall {
@@ -110,6 +126,60 @@ const findWorkspaceRoot = (start: string): string | null => {
const readJson = <T>(filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
if (compareSourceRoot) {
const resolvedCompareRoot = path.resolve(compareSourceRoot);
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-'));
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 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',
'-',
],
{
input: fixtureJson,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}
);
return JSON.parse(stdout) as ReferenceTrace;
} finally {
fs.rmSync(runtimeRoot, { recursive: true, force: true });
}
}
const stdout = execFileSync(
'docker',
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/battle_trace.php', '-'],
@@ -165,6 +235,9 @@ const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): v
).toBeLessThanOrEqual(tolerance);
};
const normalizeRandomArguments = (value: Record<string, unknown>): Record<string, unknown> =>
Array.isArray(value) && value.length === 0 ? {} : value;
const assertTraceParity = (
coreEvents: WarBattleTraceEvent[],
reference: ReferenceTrace,
@@ -176,8 +249,20 @@ const assertTraceParity = (
coreEventNames,
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
).toEqual(referenceEventNames);
expect(coreRng?.calls.map(({ operation, result }) => ({ operation, result }))).toEqual(
reference.rng.map(({ operation, result }) => ({ operation, result }))
expect(
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,
}))
);
for (let index = 0; index < reference.events.length; index += 1) {
@@ -185,10 +270,16 @@ const assertTraceParity = (
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`);
expect(core.attacker.phase, `event ${index} attacker.phase`).toBe(ref.attacker.phase);
expect(core.attacker.realPhase, `event ${index} attacker.realPhase`).toBe(ref.attacker.realPhase);
expect(core.attacker.maxPhase, `event ${index} attacker.maxPhase`).toBe(ref.attacker.maxPhase);
if (core.defender && ref.defender) {
expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind);
expectNearlyEqual(core.defender.hp, ref.defender.hp, `event ${index} defender.hp`);
expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`);
expect(core.defender.phase, `event ${index} defender.phase`).toBe(ref.defender.phase);
expect(core.defender.realPhase, `event ${index} defender.realPhase`).toBe(ref.defender.realPhase);
expect(core.defender.maxPhase, `event ${index} defender.maxPhase`).toBe(ref.defender.maxPhase);
} else {
expect(core.defender, `event ${index} defender presence`).toBe(ref.defender);
}
@@ -202,9 +293,137 @@ const assertTraceParity = (
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;
describeWithReference('ref ↔ core2026 battle differential', () => {
it('matches all scenario effects across general, direct-city, and fresh-defender combat', () => {
const unitSet = readJson<UnitSetDefinition>(
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<BattleSimRequestPayload & { startYear: number; scenarioEffect?: string }>(
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;
const coreResult = processBattleSimJob(
{
...base,
unitSet,
config,
time: { year: base.year, month: base.month, startYear: base.startYear },
scenarioEffect: entry.effect,
},
{
trace: (event) => coreEvents.push(event),
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
},
}
);
try {
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
assertTraceParity(coreEvents, reference, coreRng);
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(
'<C>●</>적군의 전멸에 <C>진격</>이 이어집니다!'
);
expect(reference.logs.defenders['3']?.generalBattleDetailLog).toContain(
'<C>●</>아군의 전멸에 상대의 <R>진격</>이 이어집니다!'
);
expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain(
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
);
}
} 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([
@@ -285,9 +504,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
try {
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
} catch (error) {
throw new Error(`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`, {
cause: error,
});
throw new Error(
`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`,
{
cause: error,
}
);
}
}
});
@@ -805,12 +1027,16 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
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')
.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')
.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}`);
@@ -910,11 +1136,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
? `\ncore=${JSON.stringify(
coreEvents
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage')
.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')
.filter(
({ event }) => event.event === 'phase_power' || event.event === 'phase_damage'
)
)}`
: '';
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`);
@@ -13,6 +13,7 @@ import {
} from '@sammo-ts/game-engine/turn/reservedTurnStore.js';
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
import { loadTurnWorldFromDatabase } from '@sammo-ts/game-engine/turn/worldLoader.js';
import { createGamePostgresConnector, type GamePrismaClient, type InputJsonValue } from '@sammo-ts/infra';
import {
@@ -49,7 +50,7 @@ const assertDedicatedDatabase = (rawUrl: string): void => {
}
};
const readFixture = (fixtureName: string): TurnCommandFixtureRequest => {
const readFixture = (fixtureName: string, scenarioEffect?: string): TurnCommandFixtureRequest => {
const fixturePath = path.join(
workspaceRoot!,
`docker_compose_files/reference/fixtures/turn-differential/${fixtureName}`
@@ -64,6 +65,7 @@ const readFixture = (fixtureName: string): TurnCommandFixtureRequest => {
world: {
...fixture.setup?.world,
hiddenSeed: 'turn-command-differential-seed',
...(scenarioEffect ? { scenarioEffect } : {}),
},
generals: fixture.setup?.generals?.map((general) => ({
...general,
@@ -117,6 +119,42 @@ integration('live sortie PostgreSQL persistence retry', () => {
await disconnect?.();
});
it('rejects an unknown persisted scenario effect before turn execution', async () => {
await db.worldState.create({
data: {
id: 1,
scenarioCode: 'invalid-scenario-effect',
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
config: asJson({
stat: {
total: 300,
min: 10,
max: 100,
npcTotal: 150,
npcMax: 50,
npcMin: 10,
chiefMin: 65,
},
iconPath: '',
map: {},
const: {},
environment: {
mapName: 'che',
unitSet: 'che',
scenarioEffect: 'event_Missing',
},
}),
meta: {},
},
});
await expect(loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! })).rejects.toThrow(
'world_state.config is invalid'
);
});
it.each([
['conquest and nation collapse', 'live-sortie-conquest.json'],
['collapsed nation conflict cleanup', 'live-sortie-collapse-conflict.json'],
@@ -127,15 +165,25 @@ integration('live sortie PostgreSQL persistence retry', () => {
['emergency capital', 'live-sortie-emergency-capital.json'],
['conflict arbitration', 'live-sortie-conflict-arbitration.json'],
['tied conflict', 'live-sortie-conflict-tie.json'],
['StrongAttacker scenario effect', 'live-sortie-multiple-defenders.json', 'event_StrongAttacker'],
])(
'%s rolls back a lost lease and flushes exactly once on retry',
async (_label, fixtureName) => {
const request = readFixture(fixtureName);
async (_label, fixtureName, scenarioEffect?: string) => {
const request = readFixture(fixtureName, scenarioEffect);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const expected = await runCoreTurnCommandTrace(request, reference.before);
if (scenarioEffect) {
expect(expected.rng).toEqual(reference.rng);
expect(
reference.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('진격'))
).toBe(true);
expect(
expected.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('진격'))
).toBe(true);
}
const coreArgs = resolveCoreTurnCommandArgs(request);
const unitSet = await loadUnitSetDefinitionByName('che');
const map = await loadMapDefinitionByName('che');
@@ -431,6 +479,8 @@ integration('live sortie PostgreSQL persistence retry', () => {
revision: committedRevision.revision,
leaseOwner: committedRevision.leaseOwner,
});
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.scenarioConfig.environment.scenarioEffect).toBe(scenarioEffect ?? null);
} finally {
await hooks.close();
}