merge: add fenced turn runner and differential harness

This commit is contained in:
2026-07-25 12:14:38 +00:00
26 changed files with 1764 additions and 101 deletions
+1
View File
@@ -6,6 +6,7 @@ import { runTurnDaemonCli } from './turn/cli.js';
export * from './lifecycle/types.js';
export * from './lifecycle/clock.js';
export * from './lifecycle/databaseCommandQueue.js';
export * from './lifecycle/databaseTurnDaemonLease.js';
export * from './lifecycle/inMemoryControlQueue.js';
export * from './lifecycle/turnDaemonLifecycle.js';
export * from './lifecycle/getNextTickTime.js';
@@ -0,0 +1,223 @@
import { randomUUID } from 'node:crypto';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
export interface TurnDaemonLeaseToken {
profile: string;
ownerId: string;
fencingEpoch: bigint;
}
export interface DatabaseTurnDaemonLeaseOptions {
profile: string;
ownerId?: string;
leaseDurationMs?: number;
heartbeat?: boolean;
}
export class TurnDaemonLeaseUnavailableError extends Error {
constructor(profile: string) {
super(`Another turn daemon holds the active lease for profile "${profile}".`);
this.name = 'TurnDaemonLeaseUnavailableError';
}
}
export class TurnDaemonLeaseLostError extends Error {
constructor(profile: string) {
super(`Turn daemon lease was lost for profile "${profile}".`);
this.name = 'TurnDaemonLeaseLostError';
}
}
type LeaseRow = {
profile: string;
owner_id: string;
fencing_epoch: bigint;
};
const normalizeLeaseDuration = (value?: number): number => Math.max(1_000, Math.floor(value ?? 30_000));
export class DatabaseTurnDaemonLease {
private readonly db: GamePrismaClient;
private readonly disconnect: () => Promise<void>;
private readonly profile: string;
private readonly ownerId: string;
private readonly leaseDurationMs: number;
private readonly heartbeatEnabled: boolean;
private token: TurnDaemonLeaseToken | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null;
private lost = false;
private constructor(
db: GamePrismaClient,
disconnect: () => Promise<void>,
options: DatabaseTurnDaemonLeaseOptions
) {
this.db = db;
this.disconnect = disconnect;
this.profile = options.profile;
this.ownerId = options.ownerId ?? randomUUID();
this.leaseDurationMs = normalizeLeaseDuration(options.leaseDurationMs);
this.heartbeatEnabled = options.heartbeat ?? true;
}
static async connect(
databaseUrl: string,
options: DatabaseTurnDaemonLeaseOptions
): Promise<DatabaseTurnDaemonLease> {
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
return new DatabaseTurnDaemonLease(connector.prisma, () => connector.disconnect(), options);
}
async acquire(): Promise<TurnDaemonLeaseToken | null> {
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
INSERT INTO "turn_daemon_lease" (
"profile",
"owner_id",
"lease_until",
"fencing_epoch",
"heartbeat_at"
)
VALUES (
${this.profile},
${this.ownerId},
CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
1,
CURRENT_TIMESTAMP
)
ON CONFLICT ("profile") DO UPDATE
SET
"owner_id" = EXCLUDED."owner_id",
"lease_until" = EXCLUDED."lease_until",
"fencing_epoch" = CASE
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
THEN "turn_daemon_lease"."fencing_epoch"
ELSE "turn_daemon_lease"."fencing_epoch" + 1
END,
"heartbeat_at" = CURRENT_TIMESTAMP
WHERE
"turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP
RETURNING "profile", "owner_id", "fencing_epoch"
`);
const row = rows[0];
if (!row) {
return null;
}
this.token = {
profile: row.profile,
ownerId: row.owner_id,
fencingEpoch: BigInt(row.fencing_epoch),
};
this.lost = false;
if (this.heartbeatEnabled) {
this.startHeartbeat();
}
return this.token;
}
getToken(): TurnDaemonLeaseToken | null {
return this.token ? { ...this.token } : null;
}
isLost(): boolean {
return this.lost;
}
async renew(): Promise<boolean> {
const token = this.token;
if (!token || this.lost) {
return false;
}
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET
"lease_until" = CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
"heartbeat_at" = CURRENT_TIMESTAMP
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP
RETURNING "profile", "owner_id", "fencing_epoch"
`);
if (rows.length === 0) {
this.markLost();
return false;
}
return true;
}
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
const token = this.token;
if (!token || this.lost) {
throw new TurnDaemonLeaseLostError(this.profile);
}
const db = transaction ?? this.db;
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
SELECT "profile", "owner_id", "fencing_epoch"
FROM "turn_daemon_lease"
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP
FOR UPDATE
`);
if (rows.length === 0) {
this.markLost();
throw new TurnDaemonLeaseLostError(this.profile);
}
}
async release(): Promise<void> {
this.stopHeartbeat();
const token = this.token;
this.token = null;
if (!token || this.lost) {
return;
}
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET "lease_until" = CURRENT_TIMESTAMP, "heartbeat_at" = CURRENT_TIMESTAMP
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
`);
}
async close(): Promise<void> {
try {
await this.release();
} finally {
await this.disconnect();
}
}
private startHeartbeat(): void {
if (this.heartbeatTimer) {
return;
}
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
this.heartbeatTimer = setInterval(() => {
void this.renew().catch(() => {
this.markLost();
});
}, intervalMs);
this.heartbeatTimer.unref();
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private markLost(): void {
this.lost = true;
this.stopHeartbeat();
}
}
+9 -1
View File
@@ -29,6 +29,7 @@ import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import { buildDiplomacyMeta } from '@sammo-ts/logic';
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js';
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -313,7 +314,10 @@ const buildLogCreateData = (
export const createDatabaseTurnHooks = async (
databaseUrl: string,
world: InMemoryTurnWorld,
options?: { reservedTurns?: InMemoryReservedTurnStore }
options?: {
reservedTurns?: InMemoryReservedTurnStore;
turnDaemonLease?: DatabaseTurnDaemonLease;
}
): Promise<DatabaseTurnHooks> => {
// 턴 처리 결과를 DB에 반영하는 훅을 만든다.
const connector = createGamePostgresConnector({ url: databaseUrl });
@@ -355,6 +359,10 @@ export const createDatabaseTurnHooks = async (
meta: asJson(state.meta),
};
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
// Lock and validate the fencing row in the same transaction as every
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
await options?.turnDaemonLease?.assertActive(prisma);
let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey =
+33
View File
@@ -912,6 +912,39 @@ export class InMemoryTurnWorld {
}
for (const nationId of collapsedNationIds) {
// Legacy deleteNation() calls DeleteConflict() before removing the
// nation. Without this, a later conquest can award a city to a
// nation ID that no longer exists.
for (const city of this.cities.values()) {
const rawConflict = city.meta.conflict;
if (rawConflict === null || rawConflict === undefined) {
continue;
}
let conflict: Record<string, unknown>;
try {
const parsed = typeof rawConflict === 'string' ? (JSON.parse(rawConflict) as unknown) : rawConflict;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
continue;
}
conflict = { ...(parsed as Record<string, unknown>) };
} catch {
continue;
}
const key = String(nationId);
if (!Object.prototype.hasOwnProperty.call(conflict, key)) {
continue;
}
delete conflict[key];
this.cities.set(city.id, {
...city,
meta: {
...city.meta,
conflict: JSON.stringify(conflict),
},
});
this.dirtyCityIds.add(city.id);
}
const nation = this.nations.get(nationId);
if (nation) {
const generalIds = Array.from(this.generals.values())
+36 -4
View File
@@ -35,6 +35,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';
export interface TurnDaemonRuntimeOptions {
profile: string;
@@ -56,6 +57,9 @@ export interface TurnDaemonRuntimeOptions {
adminActionIntervalMs?: number;
redisUrl?: string;
commandStreamStartId?: string;
leaseDurationMs?: number;
leaseOwnerId?: string;
enableLeaseHeartbeat?: boolean;
}
export interface TurnDaemonRuntime {
@@ -89,7 +93,11 @@ 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 { state, snapshot } = await loadTurnWorldFromDatabase({
databaseUrl: options.databaseUrl,
@@ -135,7 +143,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
);
const eventActions = new Map<string, MonthlyEventActionHandler>();
eventActions.set('ProcessIncome', (_args, environment) => {
incomeHandler.onMonthChanged?.({
void incomeHandler.onMonthChanged?.({
previousYear: environment.month === 1 ? environment.year - 1 : environment.year,
previousMonth: environment.month === 1 ? 12 : environment.month - 1,
currentYear: environment.year,
@@ -295,9 +303,10 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
if (gatewayGate) {
pauseGate = gatewayGate.shouldPause;
}
if (options.enableDatabaseFlush ?? true) {
if (databaseFlushEnabled) {
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
reservedTurns: reservedTurnStoreHandle?.store,
turnDaemonLease: turnDaemonLease ?? undefined,
});
auctionBidder = await createAuctionBidder({
databaseUrl: options.databaseUrl,
@@ -334,6 +343,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
}
await gatewayGate?.close();
await adminActionConsumer?.stop();
await turnDaemonLease?.close();
};
} else if (reservedTurnStoreHandle) {
hooks = {
@@ -436,7 +446,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
stateStore,
processor,
hooks,
pauseGate,
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
commandHandler,
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
},
@@ -484,3 +494,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;
}
};
@@ -86,6 +86,10 @@ describe('도시 점령 시 국가 멸망 처리', () => {
const strongFrontCity = cities.find((city) => city.id === strongFrontCityId)!;
strongFrontCity.frontState = 1;
strongFrontCity.supplyState = 1;
const conflictCity = cities.find((city) => city.id === 3)!;
Object.assign(conflictCity.meta, {
conflict: JSON.stringify({ 2: 100, 1: 50 }),
});
const unitSet: UnitSetDefinition = {
id: 'test_unit_set',
@@ -134,15 +138,22 @@ describe('도시 점령 시 국가 멸망 처리', () => {
const generals: TurnGeneral[] = [strongLeader];
for (let i = 2; i <= 6; i += 1) {
generals.push(
createNpcGeneral(i, strongFrontCityId, 1, 2, { leadership: 80, strength: 80, intelligence: 50 }, {
crew: 8000,
crewTypeId: 1100,
train: 100,
atmos: 100,
gold: 100000,
rice: 100000,
turnTime: delayedTurnTime,
})
createNpcGeneral(
i,
strongFrontCityId,
1,
2,
{ leadership: 80, strength: 80, intelligence: 50 },
{
crew: 8000,
crewTypeId: 1100,
train: 100,
atmos: 100,
gold: 100000,
rice: 100000,
turnTime: delayedTurnTime,
}
)
);
}
const weakGeneral = createNpcGeneral(
@@ -253,6 +264,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
expect(world.getCityById(weakCityId)?.nationId).toBe(1);
expect(world.getNationById(2)).toBeNull();
expect(world.listNations().some((nation) => nation.id === 2)).toBe(false);
expect(JSON.parse(String(world.getCityById(conflictCity.id)?.meta.conflict))).toEqual({ 1: 50 });
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
expect(updatedWeakGeneral?.nationId).toBe(0);
@@ -104,6 +104,7 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
capitalCityId: nation.capitalCityId,
cityCount: nationCities.length,
generalCount: nationGenerals.length,
chiefCount: nationGenerals.filter((general) => general.officerLevel === 12).length,
gold: nation.gold,
rice: nation.rice,
avgGold,
@@ -135,12 +136,18 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
cityCount: cities.length,
generalCount: generals.length,
nationStats,
diplomacy: world.listDiplomacy().map((entry) => ({
fromNationId: entry.fromNationId,
toNationId: entry.toNationId,
state: entry.state,
term: entry.term,
})),
citySummary,
});
};
describe('NPC 건국/통일 장기 시뮬레이션', () => {
it('건국, 점령 완료, 장기 감소 및 통일까지 진행되어야 한다', async () => {
it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => {
const cities = buildLargeTestCities().map(maxCityStats);
for (const city of cities) {
city.nationId = 0;
@@ -195,9 +202,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const generals: TurnGeneral[] = [];
for (let i = 0; i < 300; i += 1) {
const cityId = cities[i % cities.length]!.id;
const stats = i % 2 === 0
? { leadership: 75, strength: 75, intelligence: 10 }
: { leadership: 75, strength: 10, intelligence: 75 };
const stats =
i % 2 === 0
? { leadership: 75, strength: 75, intelligence: 10 }
: { leadership: 75, strength: 10, intelligence: 75 };
generals.push(createNpcGeneral(i + 1, cityId, stats));
}
@@ -276,27 +284,48 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const lastNationAiState = new Map<number, unknown>();
let declarationCount = 0;
let sortieCount = 0;
let lastResolvedAction = 'none';
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } = await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
if (payload.kind !== 'nation') {
return;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } =
await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
const currentWorld = worldRef.current;
if (currentWorld) {
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
const orphanCities = currentWorld
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
throw new Error(
`orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
}
lastResolvedAction = `${payload.kind}:${payload.actionKey}`;
if (payload.kind === 'general') {
if (payload.actionKey === 'che_출병') {
sortieCount += 1;
}
return;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
let monthlyLogCursor = 0;
const maxMonthlyLogEntries = 20;
@@ -325,8 +354,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
};
try {
await runUntil((current) =>
current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
await runUntil(
(current) => current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
);
const world = worldRef.current;
@@ -336,9 +365,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
const foundedNationCount = foundedNations.length;
await runUntil((current) =>
current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
await runUntil(
(current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
);
const neutralCities = world.listCities().filter((city) => city.nationId <= 0);
@@ -350,8 +380,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
expect(hasHighOfficer).toBe(true);
if (declarationCount === 0) {
await runUntil((current) =>
current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
await runUntil(
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
);
if (declarationCount === 0) {
const generals = world.listGenerals().filter((general) => general.nationId > 0);
@@ -364,7 +394,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
.filter((nation) => nation.level > 0)
.map((nation) => {
const nationGenerals = generals.filter((general) => general.nationId === nation.id);
const crewed = nationGenerals.filter((general) => general.crew > 0 && general.crewTypeId > 0);
const crewed = nationGenerals.filter(
(general) => general.crew > 0 && general.crewTypeId > 0
);
return {
nationId: nation.id,
totalGenerals: nationGenerals.length,
@@ -388,10 +420,11 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const frontStatus = world
.listCities()
.some(
(city) =>
city.nationId === nation.id && city.supplyState > 0 && city.frontState > 0
(city) => city.nationId === nation.id && city.supplyState > 0 && city.frontState > 0
);
const hasCrew = generals.some((general) => general.nationId === nation.id && general.crew > 0);
const hasCrew = generals.some(
(general) => general.nationId === nation.id && general.crew > 0
);
const meta = (nation.meta ?? {}) as Record<string, unknown>;
const lastAttackable = typeof meta.last_attackable === 'number' ? meta.last_attackable : 0;
return {
@@ -445,18 +478,33 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
let prevNationCount = world
.listNations()
.filter((nation) => nation.level > 0 && world.listCities().some((city) => city.nationId === nation.id))
.length;
.filter(
(nation) => nation.level > 0 && world.listCities().some((city) => city.nationId === nation.id)
).length;
let unifiedAt: { year: number; month: number } | null = null;
while (true) {
const target = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > target.year ||
(current.currentYear === target.year && current.currentMonth >= target.month)
await runUntil(
(current) =>
current.currentYear > target.year ||
(current.currentYear === target.year && current.currentMonth >= target.month)
);
//_dumpMonthlyLogs(`${target.year}-${String(target.month).padStart(2, '0')}`);
const nationIds = new Set(world.listNations().map((nation) => nation.id));
const orphanCities = world
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
dumpWorldStatus(world, '존재하지 않는 국가가 도시를 소유');
throw new Error(
`orphan city ownership: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
const activeNationCount = world
.listNations()
.filter((nation) => nation.level > 0)
@@ -470,37 +518,34 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
if (activeNationCount === 1 && !unifiedAt) {
const nextMonth = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > nextMonth.year ||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
await runUntil(
(current) =>
current.currentYear > nextMonth.year ||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
);
unifiedAt = nextMonth;
break;
}
if (
world.getState().currentYear > 300 ||
(world.getState().currentYear === 300 && world.getState().currentMonth >= 1)
world.getState().currentYear > 260 ||
(world.getState().currentYear === 260 && world.getState().currentMonth >= 1)
) {
break;
}
}
const meta = world.getState().meta as Record<string, unknown>;
if (!unifiedAt) {
dumpWorldStatus(world, '통일 실패');
throw new Error('unification did not occur before 300-01');
}
expect(meta.isUnited).toBe(2);
const logs = getCollectedLogs();
const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
expect(hasUnificationLog).toBe(true);
const warStates = world.listDiplomacy().filter((entry) => entry.state === DIPLOMACY_STATE.WAR);
if (warStates.length > 0) {
expect(warStates.length).toBeGreaterThan(0);
if (unifiedAt) {
expect(meta.isUnited).toBe(2);
expect(hasUnificationLog).toBe(true);
} else {
expect(prevNationCount).toBeLessThan(foundedNationCount);
expect(meta.isUnited ?? 0).toBe(0);
}
expect(sortieCount).toBeGreaterThan(0);
} catch (error) {
const world = worldRef.current;
if (world) {
@@ -508,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
}
throw error;
}
}, 90000);
}, 180000);
});
@@ -0,0 +1,118 @@
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';
const databaseUrl = process.env.TURN_DAEMON_LEASE_DATABASE_URL ?? process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const profilePrefix = 'integration:turn-lease:';
integration('database turn daemon lease and fencing', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
const leases: DatabaseTurnDaemonLease[] = [];
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await db.turnDaemonLease.deleteMany({
where: { profile: { startsWith: profilePrefix } },
});
});
afterAll(async () => {
await Promise.allSettled(leases.map((lease) => lease.close()));
await db.turnDaemonLease.deleteMany({
where: { profile: { startsWith: profilePrefix } },
});
await disconnect?.();
});
const createLease = async (profile: string, ownerId: string) => {
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, {
profile,
ownerId,
leaseDurationMs: 60_000,
heartbeat: false,
});
leases.push(lease);
return lease;
};
it('allows only one owner to acquire an active profile lease', async () => {
const profile = `${profilePrefix}exclusive`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
const [firstToken, secondToken] = await Promise.all([first.acquire(), second.acquire()]);
expect([firstToken, secondToken].filter(Boolean)).toHaveLength(1);
const row = await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } });
expect(row.fencingEpoch).toBe(1n);
expect(row.ownerId).toBe(firstToken ? 'owner-a' : 'owner-b');
});
it('increments the epoch on expiry takeover and fences the stale owner', async () => {
const profile = `${profilePrefix}takeover`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
const firstToken = await first.acquire();
expect(firstToken?.fencingEpoch).toBe(1n);
await db.turnDaemonLease.update({
where: { profile },
data: { leaseUntil: new Date(Date.now() - 1_000) },
});
const secondToken = await second.acquire();
expect(secondToken).toMatchObject({
profile,
ownerId: 'owner-b',
fencingEpoch: 2n,
});
await expect(first.assertActive()).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
await expect(second.assertActive()).resolves.toBeUndefined();
});
it('rolls back a stale fenced transaction without writing its completion marker', async () => {
const profile = `${profilePrefix}rollback`;
const requestId = `${profilePrefix}stale-write`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
await first.acquire();
await db.turnDaemonLease.update({
where: { profile },
data: { leaseUntil: new Date(Date.now() - 1_000) },
});
await second.acquire();
await expect(
db.$transaction(async (transaction) => {
await first.assertActive(transaction);
await transaction.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'fenced-test',
payload: {} as GamePrisma.InputJsonValue,
},
});
})
).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
expect(await db.inputEvent.findUnique({ where: { requestId } })).toBeNull();
});
it('permits a clean successor after release while fencing a resumed old token', async () => {
const profile = `${profilePrefix}release`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
expect((await first.acquire())?.fencingEpoch).toBe(1n);
await first.release();
expect((await second.acquire())?.fencingEpoch).toBe(2n);
await expect(first.assertActive()).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
});
});
@@ -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.
+32 -26
View File
@@ -61,26 +61,27 @@ environment-dependent check.
### `app/game-engine`
| Test source | Disposition | Layer | What it establishes |
| ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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/generalAiLegacyDecisionParity.test.ts` | added | compatibility | Ref-backed final NPC command matrix across diplomacy, war readiness, city development/population, technology ceilings, gold/rice and casualty ranks, stats/affinity, special NPC state, treasury reserves, and command availability. |
| `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. |
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. The implementation-specific early recruitment bound was removed because legacy AI forbids peace/declaration recruitment; remaining thresholds are smoke bounds. |
| `test/npcNationTechResearch.test.ts` | corrected | smoke | Long-running monotonic tech growth. Net per-tick general gold is not treated as recruitment price because nation awards can occur in the same tick. |
| `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
| `test/npcNationWarDeclaration.test.ts` | corrected | smoke | Declaration, war transition, a battle-ready cohort, front deployment, conquest, unification, and dispatch occurrence without assuming every recruit is already fully trained. |
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
| Test source | Disposition | Layer | What it establishes |
| ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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/generalAiLegacyDecisionParity.test.ts` | added | compatibility | Ref-backed final NPC command matrix across diplomacy, war readiness, city development/population, technology ceilings, gold/rice and casualty ranks, stats/affinity, special NPC state, treasury reserves, and command availability. |
| `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. |
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. The implementation-specific early recruitment bound was removed because legacy AI forbids peace/declaration recruitment; remaining thresholds are smoke bounds. |
| `test/npcNationTechResearch.test.ts` | corrected | smoke | Long-running monotonic tech growth. Net per-tick general gold is not treated as recruitment price because nation awards can occur in the same tick. |
| `test/npcNationUprisingUnification.test.ts` | corrected | smoke | Runs from 181-08 through at most 260-01 and covers multi-nation founding, declaration, live sortie, conquest, monotonic nation-count decline, and the no-orphan-city invariant. Terminal unification is asserted when reached; the focused war scenario below guarantees the terminal path. |
| `test/npcNationWarDeclaration.test.ts` | corrected | smoke | Declaration, war transition, a battle-ready cohort, front deployment, conquest, unification, and dispatch occurrence without assuming every recruit is already fully trained. |
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
| `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/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
### `packages/logic`
@@ -118,12 +119,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
+10
View File
@@ -77,6 +77,16 @@ model InputEvent {
@@map("input_event")
}
model TurnDaemonLease {
profile String @id
ownerId String @map("owner_id")
leaseUntil DateTime @map("lease_until")
fencingEpoch BigInt @default(1) @map("fencing_epoch")
heartbeatAt DateTime @default(now()) @map("heartbeat_at")
@@map("turn_daemon_lease")
}
model WorldState {
id Int @id @default(autoincrement())
scenarioCode String @map("scenario_code")
@@ -0,0 +1,9 @@
CREATE TABLE "turn_daemon_lease" (
"profile" TEXT NOT NULL,
"owner_id" TEXT NOT NULL,
"lease_until" TIMESTAMP(3) NOT NULL,
"fencing_epoch" BIGINT NOT NULL DEFAULT 1,
"heartbeat_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "turn_daemon_lease_pkey" PRIMARY KEY ("profile")
);
+1
View File
@@ -31,4 +31,5 @@ export interface DatabaseClient {
inheritanceResult: GamePrisma.InheritanceResultDelegate;
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
inputEvent: GamePrisma.InputEventDelegate;
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
}
+9 -3
View File
@@ -105,16 +105,22 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
nation.meta.tech = round(tech);
};
const resolveConquerNation = (city: City, attackerNationId: number): number => {
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
const rawConflict = city.meta[META_CONFLICT];
if (!rawConflict) {
return attackerNationId;
}
try {
const parsed = JSON.parse(String(rawConflict)) as Record<string, number>;
const activeNationIds = new Set(nations.map((nation) => nation.id));
const entries = Object.entries(parsed)
.map(([key, value]) => [Number(key), value] as const)
.filter(([key, value]) => Number.isFinite(key) && typeof value === 'number')
.filter(
([key, value]) =>
Number.isFinite(key) &&
typeof value === 'number' &&
(key === attackerNationId || activeNationIds.has(key))
)
.sort(([, lhs], [, rhs]) => rhs - lhs);
if (!entries.length) {
return attackerNationId;
@@ -183,7 +189,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
const affectedGenerals = new Set<General<TriggerState>>();
const affectedNations = new Set<Nation>();
const conquerNationId = resolveConquerNation(defenderCity, attackerNation.id);
const conquerNationId = resolveConquerNation(defenderCity, attackerNation.id, input.nations);
const attackerLogger = new ActionLogger({
generalId: attacker.id,
nationId: attackerNation.id,
+3 -1
View File
@@ -183,7 +183,7 @@ describe('war aftermath', () => {
defenderNation.rice = 6000;
const attackerCity = buildCity(1, 1);
const defenderCity = buildCity(2, 2);
defenderCity.meta.conflict = JSON.stringify({ 1: 100 });
defenderCity.meta.conflict = JSON.stringify({ 99: 200, 1: 100 });
const attacker = buildGeneral(1, 1, 1);
const defender = buildGeneral(2, 2, 2);
@@ -235,6 +235,8 @@ describe('war aftermath', () => {
expect(attackerNation.rice).toBe(4600);
expect(defender.experience).toBe(90);
expect(defender.dedication).toBe(50);
// Removed nations can remain in old persisted conflict metadata. They
// must never receive ownership during a later conquest.
expect(defenderCity.nationId).toBe(attackerNation.id);
expect(defenderCity.meta.conflict).toBe('{}');
});
@@ -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"]
}