diff --git a/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts b/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts index 97faec6..a40fe2d 100644 --- a/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts +++ b/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts @@ -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; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 399f072..f71e8ed 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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 => { +const createTurnDaemonRuntimeWithLease = async ( + options: TurnDaemonRuntimeOptions, + databaseFlushEnabled: boolean, + turnDaemonLease: DatabaseTurnDaemonLease | null +): Promise => { // 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 => { + 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; + } +}; diff --git a/app/game-engine/test/turnDaemonLease.integration.test.ts b/app/game-engine/test/turnDaemonLease.integration.test.ts index 16c7be1..e5c5ba7 100644 --- a/app/game-engine/test/turnDaemonLease.integration.test.ts +++ b/app/game-engine/test/turnDaemonLease.integration.test.ts @@ -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); diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md new file mode 100644 index 0000000..ec931f8 --- /dev/null +++ b/docs/architecture/turn-state-differential-testing.md @@ -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. diff --git a/docs/test-suite-audit.md b/docs/test-suite-audit.md index 8355089..b3be22d 100644 --- a/docs/test-suite-audit.md +++ b/docs/test-suite-audit.md @@ -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 diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts new file mode 100644 index 0000000..335d245 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -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; + generals: Array>; + cities: Array>; + nations: Array>; + diplomacy: Array>; + generalTurns: Array>; + nationTurns: Array>; + logs: Array>; + messages: Array>; + 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; + result: unknown; + }>; +} + +const legacyArgumentAliases: Readonly> = { + 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) + .map(([key, entry]) => [legacyArgumentAliases[key] ?? key, canonicalizeTurnCommandArgs(entry)] as const) + .sort(([left], [right]) => left.localeCompare(right)) + ); +}; + +const asRecord = (value: unknown): Record => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; + +const readNumber = (record: Record, key: string, fallback = 0): number => { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +}; + +const readString = (record: Record, 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>; + cities: Array>; + nations: Array>; + diplomacy: Array>; + generalTurns: Array>; + nationTurns: Array>; + logs: Array>; +}): 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, + }, + }; +}; diff --git a/tools/integration-tests/src/turn-differential/compare.ts b/tools/integration-tests/src/turn-differential/compare.ts new file mode 100644 index 0000000..73b9c31 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/compare.ts @@ -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; + +const entityKey = (value: Record, 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, index) + : String(index); + flatten(entry, `${path}[${key}]`, output); + }); + return; + } + if (typeof value === 'object' && value !== null) { + const record = value as Record; + 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(); + 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 => { + const beforeFlat = canonicalFlatSnapshot(before); + const afterFlat = canonicalFlatSnapshot(after); + const paths = [...new Set([...beforeFlat.keys(), ...afterFlat.keys()])].sort(); + const delta = new Map(); + 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), + })); +}; diff --git a/tools/integration-tests/src/turn-differential/databaseSnapshot.ts b/tools/integration-tests/src/turn-differential/databaseSnapshot.ts new file mode 100644 index 0000000..589d10c --- /dev/null +++ b/tools/integration-tests/src/turn-differential/databaseSnapshot.ts @@ -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 => { + 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(); + } +}; diff --git a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts new file mode 100644 index 0000000..f5d46d2 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts @@ -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; +}; diff --git a/tools/integration-tests/src/turn-differential/trace.ts b/tools/integration-tests/src/turn-differential/trace.ts new file mode 100644 index 0000000..6fd30b0 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/trace.ts @@ -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 => { + 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 ?? [], + }; +}; diff --git a/tools/integration-tests/test/turnCommandReference.integration.test.ts b/tools/integration-tests/test/turnCommandReference.integration.test.ts new file mode 100644 index 0000000..195ea9e --- /dev/null +++ b/tools/integration-tests/test/turnCommandReference.integration.test.ts @@ -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); + }); +}); diff --git a/tools/integration-tests/test/turnSnapshotComparator.test.ts b/tools/integration-tests/test/turnSnapshotComparator.test.ts new file mode 100644 index 0000000..6579135 --- /dev/null +++ b/tools/integration-tests/test/turnSnapshotComparator.test.ts @@ -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 => ({ + 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, + }); + }); +}); diff --git a/tools/integration-tests/test/turnSnapshotCoreDatabase.integration.test.ts b/tools/integration-tests/test/turnSnapshotCoreDatabase.integration.test.ts new file mode 100644 index 0000000..2d3d4c9 --- /dev/null +++ b/tools/integration-tests/test/turnSnapshotCoreDatabase.integration.test.ts @@ -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) | 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', + }); + }); +}); diff --git a/tools/integration-tests/test/turnSnapshotReference.integration.test.ts b/tools/integration-tests/test/turnSnapshotReference.integration.test.ts new file mode 100644 index 0000000..50381d0 --- /dev/null +++ b/tools/integration-tests/test/turnSnapshotReference.integration.test.ts @@ -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: [], + }); + }); +}); diff --git a/tools/integration-tests/test/turnTraceFiles.integration.test.ts b/tools/integration-tests/test/turnTraceFiles.integration.test.ts new file mode 100644 index 0000000..dd22f71 --- /dev/null +++ b/tools/integration-tests/test/turnTraceFiles.integration.test.ts @@ -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([]); + }); +}); diff --git a/tools/integration-tests/tsconfig.json b/tools/integration-tests/tsconfig.json index 13251ee..c9b8d73 100644 --- a/tools/integration-tests/tsconfig.json +++ b/tools/integration-tests/tsconfig.json @@ -4,5 +4,5 @@ "types": ["node"], "noEmit": true }, - "include": ["test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] }