test: add turn command state differential harness

This commit is contained in:
2026-07-25 11:38:42 +00:00
parent 0151055f94
commit 111d3c7074
16 changed files with 1200 additions and 31 deletions
@@ -35,8 +35,7 @@ type LeaseRow = {
fencing_epoch: bigint;
};
const normalizeLeaseDuration = (value?: number): number =>
Math.max(1_000, Math.floor(value ?? 30_000));
const normalizeLeaseDuration = (value?: number): number => Math.max(1_000, Math.floor(value ?? 30_000));
export class DatabaseTurnDaemonLease {
private readonly db: GamePrismaClient;
+28 -18
View File
@@ -34,10 +34,7 @@ import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
import { createYearbookHandler } from './yearbookHandler.js';
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from './monthlyEventHandler.js';
import {
DatabaseTurnDaemonLease,
TurnDaemonLeaseUnavailableError,
} from '../lifecycle/databaseTurnDaemonLease.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
export interface TurnDaemonRuntimeOptions {
profile: string;
@@ -95,21 +92,12 @@ const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.
return resolveRedisConfigFromEnv(env);
};
export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions): Promise<TurnDaemonRuntime> => {
const createTurnDaemonRuntimeWithLease = async (
options: TurnDaemonRuntimeOptions,
databaseFlushEnabled: boolean,
turnDaemonLease: DatabaseTurnDaemonLease | null
): Promise<TurnDaemonRuntime> => {
// DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다.
const databaseFlushEnabled = options.enableDatabaseFlush ?? true;
const turnDaemonLease = databaseFlushEnabled
? await DatabaseTurnDaemonLease.connect(options.databaseUrl, {
profile: options.profileName ?? options.profile,
ownerId: options.leaseOwnerId,
leaseDurationMs: options.leaseDurationMs,
heartbeat: options.enableLeaseHeartbeat,
})
: null;
if (turnDaemonLease && !(await turnDaemonLease.acquire())) {
await turnDaemonLease.close();
throw new TurnDaemonLeaseUnavailableError(options.profileName ?? options.profile);
}
const { state, snapshot } = await loadTurnWorldFromDatabase({
databaseUrl: options.databaseUrl,
mapOptions: options.mapOptions,
@@ -496,3 +484,25 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
close,
};
};
export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions): Promise<TurnDaemonRuntime> => {
const databaseFlushEnabled = options.enableDatabaseFlush ?? true;
const turnDaemonLease = databaseFlushEnabled
? await DatabaseTurnDaemonLease.connect(options.databaseUrl, {
profile: options.profileName ?? options.profile,
ownerId: options.leaseOwnerId,
leaseDurationMs: options.leaseDurationMs,
heartbeat: options.enableLeaseHeartbeat,
})
: null;
if (turnDaemonLease && !(await turnDaemonLease.acquire())) {
await turnDaemonLease.close();
throw new TurnDaemonLeaseUnavailableError(options.profileName ?? options.profile);
}
try {
return await createTurnDaemonRuntimeWithLease(options, databaseFlushEnabled, turnDaemonLease);
} catch (error) {
await turnDaemonLease?.close();
throw error;
}
};
@@ -2,10 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import {
DatabaseTurnDaemonLease,
TurnDaemonLeaseLostError,
} from '../src/lifecycle/databaseTurnDaemonLease.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
const databaseUrl = process.env.TURN_DAEMON_LEASE_DATABASE_URL ?? process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
@@ -0,0 +1,156 @@
# Turn command state differential testing
## Scope
This harness compares observable state changes produced by:
- general reserved commands;
- nation reserved commands;
- live `che_출병`, including persisted battle aftermath.
It complements the battle simulator differential suite. The simulator suite
compares phase order, RNG and battle numbers. This harness compares the command
boundary, logs, queues, diplomacy and final database state.
## Execution flow
```text
canonical case
├─ ref ng_compare command runner
│ ├─ clone root/HWE MariaDB
│ ├─ execute legacy GeneralCommand or NationCommand
│ └─ before/after snapshot + command RNG trace
└─ core2026 execution boundary
├─ snapshot selected PostgreSQL rows
├─ execute reserved turn or daemon command
└─ before/after snapshot + command RNG trace
ref delta ─┐
├─ exact semantic path comparison
core delta ┘
```
The comparison uses entity IDs rather than physical row order. Numeric leaves
are compared as `after - before`, so a fixture may use different harmless
starting balances while still requiring the same effect. Insertions, deletions
and non-numeric changes retain explicit before/after values.
Logs and messages are sequence-sensitive and are therefore paired by observed
order rather than database primary key. Their physical IDs can be ignored
without losing ordering checks. Legacy `*ID` command argument keys are
normalized to the core `*Id` spelling before command identity comparison.
## Components
- Ref `ng_compare`
- `hwe/compare/turn_state_snapshot.php`: read-only canonical projection.
- `hwe/compare/turn_command_trace.php`: guarded CLI action runner.
- Workspace reference stack
- `scripts/run-turn-differential-case.sh`: clones both MariaDB databases,
injects temporary DB configuration, runs one case and deletes only
validated `sammo_td_*` databases.
- Core integration tools
- `canonical.ts`: shared snapshot and trace contracts.
- `databaseSnapshot.ts`: PostgreSQL projection.
- `trace.ts`: before/execute/after capture boundary.
- `compare.ts`: exact snapshot and delta comparison.
- `turnTraceFiles.integration.test.ts`: compares saved ref/core traces.
The ref runner refuses mutation unless `TURN_DIFFERENTIAL_ENABLED=1` is present.
The wrapper injects it only into the disposable tool container. Direct
execution against the reference database is not a supported workflow.
Two committed cases exercise the guarded runner end to end:
- `nation-declaration.json`: chief nation command, directed diplomacy
`state/term` changes and national messages.
- `live-sortie-conquest.json`: real `che_출병 -> processWar`, city capture,
defeated-general neutralization and last-city nation collapse.
Each case may include a structured `setup` object for `world`, `nations`,
`cities`, `generals` and `diplomacy`. The runner accepts only explicitly mapped
fields; it does not accept SQL. Setup and command mutation happen only inside
the cloned `sammo_td_*` databases.
## Case request
```json
{
"kind": "general",
"actorGeneralId": 101,
"action": "che_출병",
"args": { "destCityID": 12 },
"observe": {
"generalIds": [101, 201],
"cityIds": [11, 12],
"nationIds": [1, 2],
"logAfterId": 0,
"messageAfterId": 0
}
}
```
Legacy argument spelling is retained at the ref boundary (`destCityID`,
`destNationID`). The core execution request uses the core spelling
(`destCityId`, `destNationId`), while the trace's semantic entity IDs remain
the same.
Run a ref case:
```sh
cd docker_compose_files/reference
./scripts/run-turn-differential-case.sh \
fixtures/turn-differential/nation-declaration.json \
> /tmp/ref-trace.json
```
Capture the core side by wrapping the real reserved-turn or daemon execution
with `captureCoreDatabaseTurnTrace()`. Save the returned JSON, then compare:
```sh
TURN_REFERENCE_TRACE=/tmp/ref-trace.json \
TURN_CORE_TRACE=/tmp/core-trace.json \
pnpm --filter @sammo-ts/integration-tests test:integration \
turnTraceFiles.integration.test.ts
```
## Canonical coverage
Snapshots currently include:
- world year/month, tick term, last turn time and unification state;
- selected general numeric state, location, nation, troop, equipment metadata,
last turn and lifecycle counters;
- selected city ownership and all domestic/defence values;
- selected nation resources, type, capital, technology, cached power/counts;
- directed diplomacy state, term and death flag;
- general and nation reserved queues;
- action/history/battle logs;
- legacy messages and log/message watermarks.
Core2026 has no physical legacy `message` table, so its message projection is
empty. Message-equivalent effects must currently be compared through core log
effects or a command-specific projection. The saved-trace comparison explicitly
ignores the `messages` subtree for this reason; this is a documented schema
boundary, not a claim that message delivery is equal.
For live `che_출병`, use both suites:
1. `battleDifferential.test.ts` for internal war RNG/event/numeric parity.
2. Turn command traces for route choice RNG, costs, general/city/nation changes,
queues, logs, conquest and nation deletion.
## Test trust boundary
Comparator unit tests prove path identity, order independence, numeric delta
handling and deletion detection. Adapter integration tests prove that both
actual databases can produce the canonical form. They do not by themselves
claim that all 55 general and 38 nation commands match.
Compatibility is established per case only when:
1. both engines executed the requested action rather than a fallback;
2. RNG operations and results match;
3. the semantic delta comparison is empty;
4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence.
+12 -6
View File
@@ -65,6 +65,7 @@ environment-dependent check.
| ----------------------------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
| `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. |
| `test/turnDaemonLease.integration.test.ts` | kept | integration | PostgreSQL profile lease exclusivity, expiry takeover with epoch increment, stale-owner fencing rollback, and clean release handoff. Explicitly skipped without a DB URL. |
| `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
| `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. |
| `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. |
@@ -117,12 +118,17 @@ environment-dependent check.
### `tools/integration-tests`
| Test source | Disposition | Layer | What it establishes |
| --------------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/auctionFlow.test.ts` | kept | integration | Real API/PostgreSQL/Redis/daemon resource and unique-auction bidding, extension, finalization, payout, and ownership constraints. |
| `test/battleDifferential.test.ts` | kept | compatibility | PHP reference metadata and attacker/defender comparisons for all 145 scenario items, plus event order, RNG results, phase values, multi-defender, siege, and no-defender branches. The two exhaustive item cases currently fail on 13 attacker and 2 defender items; this is active compatibility evidence, not a green regression. |
| `test/initialization.test.ts` | kept | integration | Real gateway/game APIs, database/Redis reset, scenario seed, users, joins, turns, and founding state. |
| `test/orchestrator.e2e.test.ts` | kept | integration | Real PM2 game API/daemon startup, command serving, SSE completion, and persisted world update. |
| Test source | Disposition | Layer | What it establishes |
| --------------------------------------------------- | ----------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test/auctionFlow.test.ts` | kept | integration | Real API/PostgreSQL/Redis/daemon resource and unique-auction bidding, extension, finalization, payout, and ownership constraints. |
| `test/battleDifferential.test.ts` | kept | compatibility | PHP reference metadata and attacker/defender comparisons for all 145 scenario items, plus event order, RNG results, phase values, multi-defender, siege, and no-defender branches. The two exhaustive item cases currently fail on 13 attacker and 2 defender items; this is active compatibility evidence, not a green regression. |
| `test/initialization.test.ts` | kept | integration | Real gateway/game APIs, database/Redis reset, scenario seed, users, joins, turns, and founding state. |
| `test/orchestrator.e2e.test.ts` | kept | integration | Real PM2 game API/daemon startup, command serving, SSE completion, and persisted world update. |
| `test/turnSnapshotComparator.test.ts` | kept | contract | Canonical entity ordering, exact changed paths, numeric before/after delta equivalence, and live-conquest nation-deletion mismatch detection. |
| `test/turnSnapshotCoreDatabase.integration.test.ts` | kept | integration | Real PostgreSQL projection plus before/execute/after capture around a transaction boundary. Explicitly skipped without a DB URL. |
| `test/turnSnapshotReference.integration.test.ts` | kept | integration | Read-only canonical projection from the isolated PHP/MariaDB reference service. Explicitly enabled with `TURN_DIFFERENTIAL_REFERENCE=1`. |
| `test/turnCommandReference.integration.test.ts` | kept | compatibility harness | Disposable cloned-MariaDB execution of real legacy nation declaration and live sortie through conquest and nation collapse. Explicitly enabled with `TURN_DIFFERENTIAL_REFERENCE=1`. |
| `test/turnTraceFiles.integration.test.ts` | kept | compatibility harness | Saved ref/core command identity, RNG sequence and semantic delta comparison. It is skipped until both independently executed trace files are supplied. |
## Shared test support
@@ -0,0 +1,235 @@
export type CanonicalEngine = 'ref' | 'core2026';
export interface TurnSnapshotSelector {
generalIds: number[];
cityIds: number[];
nationIds: number[];
logAfterId?: number;
messageAfterId?: number;
}
export interface CanonicalTurnSnapshot {
schemaVersion: 1;
engine: CanonicalEngine;
world: Record<string, unknown>;
generals: Array<Record<string, unknown>>;
cities: Array<Record<string, unknown>>;
nations: Array<Record<string, unknown>>;
diplomacy: Array<Record<string, unknown>>;
generalTurns: Array<Record<string, unknown>>;
nationTurns: Array<Record<string, unknown>>;
logs: Array<Record<string, unknown>>;
messages: Array<Record<string, unknown>>;
watermarks: {
logId: number;
messageId: number;
};
}
export interface CanonicalTurnCommandTrace {
schemaVersion: 1;
engine: CanonicalEngine;
execution: {
kind: 'general' | 'nation';
actorGeneralId: number;
action: string;
args: unknown;
seedDomain: 'generalCommand' | 'nationCommand';
outcome?: unknown;
};
before: CanonicalTurnSnapshot;
after: CanonicalTurnSnapshot;
rng: Array<{
seq: number;
operation: string;
arguments: Record<string, unknown>;
result: unknown;
}>;
}
const legacyArgumentAliases: Readonly<Record<string, string>> = {
destCityID: 'destCityId',
destNationID: 'destNationId',
destGeneralID: 'destGeneralId',
destTroopID: 'destTroopId',
};
export const canonicalizeTurnCommandArgs = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(canonicalizeTurnCommandArgs);
}
if (typeof value !== 'object' || value === null) {
return value;
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.map(([key, entry]) => [legacyArgumentAliases[key] ?? key, canonicalizeTurnCommandArgs(entry)] as const)
.sort(([left], [right]) => left.localeCompare(right))
);
};
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const readNumber = (record: Record<string, unknown>, key: string, fallback = 0): number => {
const value = record[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
};
const readString = (record: Record<string, unknown>, key: string): string | null => {
const value = record[key];
return typeof value === 'string' ? value : null;
};
const serializeDate = (value: Date | null): string | null => value?.toISOString() ?? null;
export const projectCoreDatabaseSnapshot = (rows: {
world: {
currentYear: number;
currentMonth: number;
tickSeconds: number;
meta: unknown;
};
generals: Array<Record<string, unknown>>;
cities: Array<Record<string, unknown>>;
nations: Array<Record<string, unknown>>;
diplomacy: Array<Record<string, unknown>>;
generalTurns: Array<Record<string, unknown>>;
nationTurns: Array<Record<string, unknown>>;
logs: Array<Record<string, unknown>>;
}): CanonicalTurnSnapshot => {
const worldMeta = asRecord(rows.world.meta);
const generals = rows.generals.map((row) => {
const meta = asRecord(row.meta);
return {
id: row.id,
name: row.name,
nationId: row.nationId,
cityId: row.cityId,
troopId: row.troopId,
leadership: row.leadership,
strength: row.strength,
intelligence: row.intel,
experience: row.experience,
dedication: row.dedication,
officerLevel: row.officerLevel,
injury: row.injury,
gold: row.gold,
rice: row.rice,
crew: row.crew,
crewTypeId: row.crewTypeId,
train: row.train,
atmos: row.atmos,
age: row.age,
npcState: row.npcState,
turnTime: row.turnTime instanceof Date ? serializeDate(row.turnTime) : row.turnTime,
recentWarTime: row.recentWarTime instanceof Date ? serializeDate(row.recentWarTime) : row.recentWarTime,
lastTurn: row.lastTurn,
meta,
leadershipExp: readNumber(meta, 'leadership_exp'),
strengthExp: readNumber(meta, 'strength_exp'),
intelExp: readNumber(meta, 'intel_exp'),
killTurn: readNumber(meta, 'killturn'),
mySet: readNumber(meta, 'myset'),
};
});
const cities = rows.cities.map((row) => {
const meta = asRecord(row.meta);
return {
id: row.id,
name: row.name,
nationId: row.nationId,
level: row.level,
population: row.population,
populationMax: row.populationMax,
agriculture: row.agriculture,
agricultureMax: row.agricultureMax,
commerce: row.commerce,
commerceMax: row.commerceMax,
security: row.security,
securityMax: row.securityMax,
supplyState: row.supplyState,
frontState: row.frontState,
defence: row.defence,
defenceMax: row.defenceMax,
wall: row.wall,
wallMax: row.wallMax,
state: readNumber(meta, 'state'),
term: readNumber(meta, 'term'),
trust: row.trust,
trade: row.trade,
};
});
const nations = rows.nations.map((row) => {
const meta = asRecord(row.meta);
return {
id: row.id,
name: row.name,
color: row.color,
capitalCityId: row.capitalCityId,
gold: row.gold,
rice: row.rice,
tech: row.tech,
level: row.level,
typeCode: row.typeCode,
generalCount: readNumber(meta, 'gennum'),
power: readNumber(meta, 'power'),
war: readNumber(meta, 'war'),
meta,
};
});
const diplomacy = rows.diplomacy.map((row) => ({
fromNationId: row.srcNationId,
toNationId: row.destNationId,
state: row.stateCode,
term: row.term,
dead: row.isDead === true ? 1 : 0,
}));
const generalTurns = rows.generalTurns.map((row) => ({
generalId: row.generalId,
turnIndex: row.turnIdx,
action: row.actionCode,
args: row.arg,
}));
const nationTurns = rows.nationTurns.map((row) => ({
nationId: row.nationId,
officerLevel: row.officerLevel,
turnIndex: row.turnIdx,
action: row.actionCode,
args: row.arg,
}));
const logs = rows.logs.map((row) => ({
id: row.id,
scope: readString(row, 'scope'),
category: readString(row, 'category')?.toLowerCase() ?? null,
generalId: row.generalId,
nationId: row.nationId,
year: row.year,
month: row.month,
text: row.text,
}));
return {
schemaVersion: 1,
engine: 'core2026',
world: {
year: rows.world.currentYear,
month: rows.world.currentMonth,
tickMinutes: Math.max(1, Math.round(rows.world.tickSeconds / 60)),
turnTime: readString(worldMeta, 'lastTurnTime'),
isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')),
},
generals,
cities,
nations,
diplomacy,
generalTurns,
nationTurns,
logs,
messages: [],
watermarks: {
logId: logs.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0),
messageId: 0,
},
};
};
@@ -0,0 +1,132 @@
import type { CanonicalTurnSnapshot } from './canonical.js';
export interface SnapshotDifference {
path: string;
reference: unknown;
core: unknown;
}
export interface SnapshotComparisonOptions {
ignoredPathPatterns?: RegExp[];
numericTolerance?: number;
}
type FlatSnapshot = Map<string, unknown>;
const entityKey = (value: Record<string, unknown>, index: number): string => {
for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) {
const candidate = value[key];
if (typeof candidate === 'number' || typeof candidate === 'string') {
if (key === 'fromNationId' && value.toNationId !== undefined) {
return `${String(candidate)}->${String(value.toNationId)}`;
}
if (value.turnIndex !== undefined) {
return `${String(candidate)}:${String(value.officerLevel ?? '')}:${String(value.turnIndex)}`;
}
return String(candidate);
}
}
return String(index);
};
const flatten = (value: unknown, path: string, output: FlatSnapshot): void => {
if (Array.isArray(value)) {
value.forEach((entry, index) => {
const key =
path === 'logs' || path === 'messages'
? String(index)
: typeof entry === 'object' && entry !== null && !Array.isArray(entry)
? entityKey(entry as Record<string, unknown>, index)
: String(index);
flatten(entry, `${path}[${key}]`, output);
});
return;
}
if (typeof value === 'object' && value !== null) {
const record = value as Record<string, unknown>;
for (const key of Object.keys(record).sort()) {
flatten(record[key], path ? `${path}.${key}` : key, output);
}
return;
}
output.set(path, value);
};
const canonicalFlatSnapshot = (snapshot: CanonicalTurnSnapshot): FlatSnapshot => {
const { engine: _engine, watermarks: _watermarks, ...comparable } = snapshot;
const output = new Map<string, unknown>();
flatten(comparable, '', output);
return output;
};
const valuesEqual = (left: unknown, right: unknown, numericTolerance: number): boolean => {
if (typeof left === 'number' && typeof right === 'number') {
return Math.abs(left - right) <= numericTolerance;
}
return Object.is(left, right);
};
export const compareTurnSnapshots = (
reference: CanonicalTurnSnapshot,
core: CanonicalTurnSnapshot,
options: SnapshotComparisonOptions = {}
): SnapshotDifference[] => {
const ignored = options.ignoredPathPatterns ?? [];
const tolerance = Math.max(0, options.numericTolerance ?? 0);
const referenceFlat = canonicalFlatSnapshot(reference);
const coreFlat = canonicalFlatSnapshot(core);
const paths = [...new Set([...referenceFlat.keys(), ...coreFlat.keys()])].sort();
return paths
.filter((path) => !ignored.some((pattern) => pattern.test(path)))
.filter((path) => !valuesEqual(referenceFlat.get(path), coreFlat.get(path), tolerance))
.map((path) => ({
path,
reference: referenceFlat.get(path),
core: coreFlat.get(path),
}));
};
export const buildTurnSnapshotDelta = (
before: CanonicalTurnSnapshot,
after: CanonicalTurnSnapshot
): Map<string, unknown> => {
const beforeFlat = canonicalFlatSnapshot(before);
const afterFlat = canonicalFlatSnapshot(after);
const paths = [...new Set([...beforeFlat.keys(), ...afterFlat.keys()])].sort();
const delta = new Map<string, unknown>();
for (const path of paths) {
const previous = beforeFlat.get(path);
const next = afterFlat.get(path);
if (Object.is(previous, next)) {
continue;
}
if (typeof previous === 'number' && typeof next === 'number') {
delta.set(path, next - previous);
} else {
delta.set(path, { before: previous, after: next });
}
}
return delta;
};
export const compareTurnSnapshotDeltas = (
referenceBefore: CanonicalTurnSnapshot,
referenceAfter: CanonicalTurnSnapshot,
coreBefore: CanonicalTurnSnapshot,
coreAfter: CanonicalTurnSnapshot,
options: SnapshotComparisonOptions = {}
): SnapshotDifference[] => {
const ignored = options.ignoredPathPatterns ?? [];
const tolerance = Math.max(0, options.numericTolerance ?? 0);
const referenceDelta = buildTurnSnapshotDelta(referenceBefore, referenceAfter);
const coreDelta = buildTurnSnapshotDelta(coreBefore, coreAfter);
const paths = [...new Set([...referenceDelta.keys(), ...coreDelta.keys()])].sort();
return paths
.filter((path) => !ignored.some((pattern) => pattern.test(path)))
.filter((path) => !valuesEqual(referenceDelta.get(path), coreDelta.get(path), tolerance))
.map((path) => ({
path,
reference: referenceDelta.get(path),
core: coreDelta.get(path),
}));
};
@@ -0,0 +1,67 @@
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { projectCoreDatabaseSnapshot, type CanonicalTurnSnapshot, type TurnSnapshotSelector } from './canonical.js';
export const readCoreDatabaseSnapshot = async (
databaseUrl: string,
selector: TurnSnapshotSelector
): Promise<CanonicalTurnSnapshot> => {
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const db = connector.prisma;
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
const [generals, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
db.general.findMany({
where: { id: { in: selector.generalIds } },
orderBy: { id: 'asc' },
}),
db.city.findMany({
where: { id: { in: selector.cityIds } },
orderBy: { id: 'asc' },
}),
db.nation.findMany({
where: { id: { in: selector.nationIds } },
orderBy: { id: 'asc' },
}),
db.diplomacy.findMany({
where: {
srcNationId: { in: selector.nationIds },
destNationId: { in: selector.nationIds },
},
orderBy: [{ srcNationId: 'asc' }, { destNationId: 'asc' }],
}),
db.generalTurn.findMany({
where: { generalId: { in: selector.generalIds } },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
}),
db.nationTurn.findMany({
where: { nationId: { in: selector.nationIds } },
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }],
}),
db.logEntry.findMany({
where: {
id: { gt: selector.logAfterId ?? 0 },
OR: [
{ scope: 'SYSTEM' },
{ generalId: { in: selector.generalIds } },
{ nationId: { in: selector.nationIds } },
],
},
orderBy: { id: 'asc' },
}),
]);
return projectCoreDatabaseSnapshot({
world,
generals,
cities,
nations,
diplomacy,
generalTurns,
nationTurns,
logs,
});
} finally {
await connector.disconnect();
}
};
@@ -0,0 +1,64 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import type { CanonicalTurnCommandTrace, CanonicalTurnSnapshot, TurnSnapshotSelector } from './canonical.js';
export const findTurnDifferentialWorkspaceRoot = (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/turn_state_snapshot.php'))
) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
};
export const readReferenceDatabaseSnapshot = (
workspaceRoot: string,
selector: TurnSnapshotSelector
): CanonicalTurnSnapshot => {
const stdout = execFileSync(
'docker',
[
'compose',
'--profile',
'tools',
'run',
'--rm',
'-T',
'time-tool',
'php',
'/var/www/html/hwe/compare/turn_state_snapshot.php',
],
{
cwd: path.join(workspaceRoot, 'docker_compose_files/reference'),
input: JSON.stringify({ observe: selector }),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}
);
return JSON.parse(stdout) as CanonicalTurnSnapshot;
};
export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath: string): CanonicalTurnCommandTrace => {
const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference');
const resolvedFixture = path.resolve(stackDirectory, fixturePath);
const fixtureRoot = path.join(stackDirectory, 'fixtures/turn-differential');
if (resolvedFixture !== fixtureRoot && !resolvedFixture.startsWith(`${fixtureRoot}${path.sep}`)) {
throw new Error(`Reference turn fixture must be under ${fixtureRoot}`);
}
const stdout = execFileSync('./scripts/run-turn-differential-case.sh', [resolvedFixture], {
cwd: stackDirectory,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return JSON.parse(stdout) as CanonicalTurnCommandTrace;
};
@@ -0,0 +1,42 @@
import type { CanonicalTurnCommandTrace, TurnSnapshotSelector } from './canonical.js';
import { readCoreDatabaseSnapshot } from './databaseSnapshot.js';
export interface CoreTurnTraceRequest {
kind: 'general' | 'nation';
actorGeneralId: number;
action: string;
args: unknown;
observe: TurnSnapshotSelector;
}
export const captureCoreDatabaseTurnTrace = async (
databaseUrl: string,
request: CoreTurnTraceRequest,
execute: () => Promise<{
outcome?: unknown;
rng?: CanonicalTurnCommandTrace['rng'];
}>
): Promise<CanonicalTurnCommandTrace> => {
const before = await readCoreDatabaseSnapshot(databaseUrl, request.observe);
const result = await execute();
const after = await readCoreDatabaseSnapshot(databaseUrl, {
...request.observe,
logAfterId: request.observe.logAfterId ?? before.watermarks.logId,
messageAfterId: request.observe.messageAfterId ?? before.watermarks.messageId,
});
return {
schemaVersion: 1,
engine: 'core2026',
execution: {
kind: request.kind,
actorGeneralId: request.actorGeneralId,
action: request.action,
args: request.args,
seedDomain: request.kind === 'general' ? 'generalCommand' : 'nationCommand',
outcome: result.outcome,
},
before,
after,
rng: result.rng ?? [],
};
};
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTrace,
} from '../src/turn-differential/referenceSnapshot.js';
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
integration('legacy command trace runner', () => {
it('runs a nation declaration against a disposable cloned database', () => {
const trace = runReferenceTurnCommandTrace(
workspaceRoot!,
'fixtures/turn-differential/nation-declaration.json'
);
expect(trace.execution).toMatchObject({
kind: 'nation',
action: 'che_선전포고',
seedDomain: 'nationCommand',
});
expect(trace.rng).toEqual([]);
expect(trace.after.diplomacy).toEqual([
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 1, term: 24 }),
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 1, term: 24 }),
]);
expect(trace.after.messages).toHaveLength(2);
});
it('runs live sortie through conquest and nation collapse', () => {
const trace = runReferenceTurnCommandTrace(
workspaceRoot!,
'fixtures/turn-differential/live-sortie-conquest.json'
);
expect(trace.execution).toMatchObject({
kind: 'general',
action: 'che_출병',
seedDomain: 'generalCommand',
});
expect(trace.rng).toEqual([
{
seq: 0,
operation: 'nextInt',
arguments: { maxInclusive: 0 },
result: 0,
},
]);
expect(trace.after.cities).toContainEqual(expect.objectContaining({ id: 70, nationId: 1 }));
expect(trace.after.nations).not.toContainEqual(expect.objectContaining({ id: 2 }));
expect(trace.after.generals).toContainEqual(expect.objectContaining({ id: 2, nationId: 0 }));
expect(trace.after.logs.some((log) => String(log.text).includes('점령'))).toBe(true);
expect(trace.after.logs.some((log) => String(log.text).includes('멸망'))).toBe(true);
});
});
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { canonicalizeTurnCommandArgs, type CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js';
import {
buildTurnSnapshotDelta,
compareTurnSnapshotDeltas,
compareTurnSnapshots,
} from '../src/turn-differential/compare.js';
const snapshot = (
engine: 'ref' | 'core2026',
overrides: Partial<CanonicalTurnSnapshot> = {}
): CanonicalTurnSnapshot => ({
schemaVersion: 1,
engine,
world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }],
nations: [{ id: 1, gold: 0, rice: 0 }],
diplomacy: [],
generalTurns: [{ generalId: 1, turnIndex: 0, action: 'che_농지개간', args: null }],
nationTurns: [],
logs: [],
messages: [],
watermarks: { logId: 0, messageId: 0 },
...overrides,
});
describe('turn snapshot differential comparator', () => {
it('compares entity arrays by semantic identity instead of database row order', () => {
const reference = snapshot('ref', {
cities: [
{ id: 2, nationId: 2, agriculture: 900 },
{ id: 1, nationId: 1, agriculture: 1000 },
],
});
const core = snapshot('core2026', {
cities: [
{ id: 1, nationId: 1, agriculture: 1000 },
{ id: 2, nationId: 2, agriculture: 900 },
],
});
expect(compareTurnSnapshots(reference, core)).toEqual([]);
});
it('normalizes legacy ID argument spelling at the trace boundary', () => {
expect(
canonicalizeTurnCommandArgs({
destCityID: 70,
nested: [{ destNationID: 2 }],
})
).toEqual({
destCityId: 70,
nested: [{ destNationId: 2 }],
});
});
it('compares ordered log content independently from database primary keys', () => {
const reference = snapshot('ref', {
logs: [{ id: 10, category: 'action', text: '동일 로그' }],
});
const core = snapshot('core2026', {
logs: [{ id: 900, category: 'action', text: '동일 로그' }],
});
expect(
compareTurnSnapshots(reference, core, {
ignoredPathPatterns: [/^logs\[[^\]]+\]\.id$/],
})
).toEqual([]);
});
it('reports exact changed paths for general and nation command state', () => {
const reference = snapshot('ref', {
diplomacy: [{ fromNationId: 1, toNationId: 2, state: 1, term: 24 }],
});
const core = snapshot('core2026', {
diplomacy: [{ fromNationId: 1, toNationId: 2, state: 1, term: 23 }],
});
expect(compareTurnSnapshots(reference, core)).toEqual([
{
path: 'diplomacy[1->2].term',
reference: 24,
core: 23,
},
]);
});
it('compares before/after deltas when database layouts or initial values differ', () => {
const refBefore = snapshot('ref');
const refAfter = snapshot('ref', {
generals: [{ id: 1, gold: 990, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
cities: [{ id: 1, nationId: 1, agriculture: 1042, defence: 500 }],
});
const coreBefore = snapshot('core2026', {
generals: [{ id: 1, gold: 2000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
cities: [{ id: 1, nationId: 1, agriculture: 3000, defence: 500 }],
});
const coreAfter = snapshot('core2026', {
generals: [{ id: 1, gold: 1990, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
cities: [{ id: 1, nationId: 1, agriculture: 3042, defence: 500 }],
});
expect(buildTurnSnapshotDelta(refBefore, refAfter).get('cities[1].agriculture')).toBe(42);
expect(compareTurnSnapshotDeltas(refBefore, refAfter, coreBefore, coreAfter)).toEqual([]);
});
it('detects live sortie persistence differences including conquest and nation collapse', () => {
const beforeRef = snapshot('ref', {
cities: [{ id: 2, nationId: 2, agriculture: 1000, defence: 1 }],
nations: [
{ id: 1, gold: 0, rice: 0 },
{ id: 2, gold: 0, rice: 0 },
],
});
const afterRef = snapshot('ref', {
cities: [{ id: 2, nationId: 1, agriculture: 1000, defence: 0 }],
nations: [{ id: 1, gold: 0, rice: 0 }],
});
const beforeCore = snapshot('core2026', {
cities: [{ id: 2, nationId: 2, agriculture: 1000, defence: 1 }],
nations: [
{ id: 1, gold: 0, rice: 0 },
{ id: 2, gold: 0, rice: 0 },
],
});
const afterCore = snapshot('core2026', {
cities: [{ id: 2, nationId: 1, agriculture: 1000, defence: 0 }],
nations: [
{ id: 1, gold: 0, rice: 0 },
{ id: 2, gold: 0, rice: 0 },
],
});
expect(compareTurnSnapshotDeltas(beforeRef, afterRef, beforeCore, afterCore)).toContainEqual({
path: 'nations[2].gold',
reference: { before: 0, after: undefined },
core: undefined,
});
});
});
@@ -0,0 +1,187 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { readCoreDatabaseSnapshot } from '../src/turn-differential/databaseSnapshot.js';
import { captureCoreDatabaseTurnTrace } from '../src/turn-differential/trace.js';
const databaseUrl = process.env.TURN_DIFFERENTIAL_DATABASE_URL ?? process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const ids = {
general: 2_147_000_101,
city: 2_147_000_102,
nation: 2_147_000_103,
};
integration('core2026 turn state database snapshot adapter', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let createdWorldId: number | null = null;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
const world = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
if (!world) {
createdWorldId = (
await db.worldState.create({
data: {
scenarioCode: 'turn-differential-adapter',
currentYear: 183,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: { lastTurnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
},
})
).id;
}
await db.nation.create({
data: {
id: ids.nation,
name: '비교국',
color: '#123456',
capitalCityId: ids.city,
gold: 100,
rice: 200,
tech: 10,
level: 1,
typeCode: 'che_중립',
meta: { gennum: 1, power: 300, war: 0 },
},
});
await db.city.create({
data: {
id: ids.city,
name: '비교도시',
level: 5,
nationId: ids.nation,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
trust: 80,
trade: 100,
defence: 500,
defenceMax: 1_000,
wall: 500,
wallMax: 1_000,
region: 1,
meta: { state: 0, term: 0 },
},
});
await db.general.create({
data: {
id: ids.general,
name: '비교장수',
nationId: ids.nation,
cityId: ids.city,
leadership: 70,
strength: 60,
intel: 80,
gold: 1_000,
rice: 1_000,
crew: 500,
crewTypeId: 1100,
train: 90,
atmos: 90,
turnTime: new Date('0183-01-01T00:00:00.000Z'),
lastTurn: { command: '휴식' },
meta: { killturn: 24, myset: 6, intel_exp: 3 },
},
});
});
afterAll(async () => {
await db.general.deleteMany({ where: { id: ids.general } });
await db.city.deleteMany({ where: { id: ids.city } });
await db.nation.deleteMany({ where: { id: ids.nation } });
if (createdWorldId !== null) {
await db.worldState.deleteMany({ where: { id: createdWorldId } });
}
await disconnect?.();
});
it('projects selected PostgreSQL rows into the canonical comparison schema', async () => {
const result = await readCoreDatabaseSnapshot(databaseUrl!, {
generalIds: [ids.general],
cityIds: [ids.city],
nationIds: [ids.nation],
});
expect(result.engine).toBe('core2026');
expect(result.generals).toContainEqual(
expect.objectContaining({
id: ids.general,
nationId: ids.nation,
cityId: ids.city,
intelligence: 80,
killTurn: 24,
mySet: 6,
})
);
expect(result.cities).toContainEqual(
expect.objectContaining({
id: ids.city,
agriculture: 1_000,
defence: 500,
state: 0,
term: 0,
})
);
expect(result.nations).toContainEqual(
expect.objectContaining({
id: ids.nation,
generalCount: 1,
power: 300,
})
);
});
it('captures before/after state around a real database execution boundary', async () => {
const trace = await captureCoreDatabaseTurnTrace(
databaseUrl!,
{
kind: 'general',
actorGeneralId: ids.general,
action: 'che_농지개간',
args: null,
observe: {
generalIds: [ids.general],
cityIds: [ids.city],
nationIds: [ids.nation],
},
},
async () => {
await db.$transaction([
db.general.update({
where: { id: ids.general },
data: { gold: { decrement: 10 } },
}),
db.city.update({
where: { id: ids.city },
data: { agriculture: { increment: 42 } },
}),
]);
return { outcome: { ok: true } };
}
);
expect(trace.before.generals[0]?.gold).toBe(1_000);
expect(trace.after.generals[0]?.gold).toBe(990);
expect(trace.before.cities[0]?.agriculture).toBe(1_000);
expect(trace.after.cities[0]?.agriculture).toBe(1_042);
expect(trace.execution).toMatchObject({
kind: 'general',
action: 'che_농지개간',
seedDomain: 'generalCommand',
});
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
findTurnDifferentialWorkspaceRoot,
readReferenceDatabaseSnapshot,
} from '../src/turn-differential/referenceSnapshot.js';
const workspaceRoot = findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
integration('legacy turn state snapshot adapter', () => {
it('exports a read-only canonical snapshot from the isolated reference service', () => {
const snapshot = readReferenceDatabaseSnapshot(workspaceRoot!, {
generalIds: [],
cityIds: [],
nationIds: [],
});
expect(snapshot).toMatchObject({
schemaVersion: 1,
engine: 'ref',
world: {
year: expect.any(Number),
month: expect.any(Number),
tickMinutes: expect.any(Number),
},
generals: [],
cities: [],
nations: [],
});
});
});
@@ -0,0 +1,42 @@
import fs from 'node:fs';
import { describe, expect, it } from 'vitest';
import { canonicalizeTurnCommandArgs, type CanonicalTurnCommandTrace } from '../src/turn-differential/canonical.js';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
const referencePath = process.env.TURN_REFERENCE_TRACE;
const corePath = process.env.TURN_CORE_TRACE;
const integration = describe.skipIf(!referencePath || !corePath);
const readTrace = (filePath: string): CanonicalTurnCommandTrace =>
JSON.parse(fs.readFileSync(filePath, 'utf8')) as CanonicalTurnCommandTrace;
integration('saved ref and core turn command traces', () => {
it('has the same command identity, RNG calls, and semantic state delta', () => {
const reference = readTrace(referencePath!);
const core = readTrace(corePath!);
expect(core.execution).toMatchObject({
kind: reference.execution.kind,
actorGeneralId: reference.execution.actorGeneralId,
action: reference.execution.action,
seedDomain: reference.execution.seedDomain,
});
expect(canonicalizeTurnCommandArgs(core.execution.args)).toEqual(
canonicalizeTurnCommandArgs(reference.execution.args)
);
expect(core.rng).toEqual(reference.rng);
const differences = compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: [
/^logs\[[^\]]+\]\.id$/,
/^logs\[[^\]]+\]\.scope$/,
/^logs\[[^\]]+\]\.nationId$/,
/^messages(?:\[|$)/,
/\.turnTime$/,
/^world\.turnTime$/,
],
});
expect(differences, JSON.stringify(differences, null, 2)).toEqual([]);
});
});
+1 -1
View File
@@ -4,5 +4,5 @@
"types": ["node"],
"noEmit": true
},
"include": ["test/**/*.ts"]
"include": ["src/**/*.ts", "test/**/*.ts"]
}