test: add turn command state differential harness
This commit is contained in:
@@ -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 ?? [],
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user