merge: add fenced turn runner and differential harness
This commit is contained in:
@@ -6,6 +6,7 @@ import { runTurnDaemonCli } from './turn/cli.js';
|
|||||||
export * from './lifecycle/types.js';
|
export * from './lifecycle/types.js';
|
||||||
export * from './lifecycle/clock.js';
|
export * from './lifecycle/clock.js';
|
||||||
export * from './lifecycle/databaseCommandQueue.js';
|
export * from './lifecycle/databaseCommandQueue.js';
|
||||||
|
export * from './lifecycle/databaseTurnDaemonLease.js';
|
||||||
export * from './lifecycle/inMemoryControlQueue.js';
|
export * from './lifecycle/inMemoryControlQueue.js';
|
||||||
export * from './lifecycle/turnDaemonLifecycle.js';
|
export * from './lifecycle/turnDaemonLifecycle.js';
|
||||||
export * from './lifecycle/getNextTickTime.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
|||||||
import { buildDiplomacyMeta } from '@sammo-ts/logic';
|
import { buildDiplomacyMeta } from '@sammo-ts/logic';
|
||||||
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
|
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
|
||||||
import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js';
|
import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js';
|
||||||
|
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||||
|
|
||||||
export interface DatabaseTurnHooks {
|
export interface DatabaseTurnHooks {
|
||||||
hooks: TurnDaemonHooks;
|
hooks: TurnDaemonHooks;
|
||||||
@@ -313,7 +314,10 @@ const buildLogCreateData = (
|
|||||||
export const createDatabaseTurnHooks = async (
|
export const createDatabaseTurnHooks = async (
|
||||||
databaseUrl: string,
|
databaseUrl: string,
|
||||||
world: InMemoryTurnWorld,
|
world: InMemoryTurnWorld,
|
||||||
options?: { reservedTurns?: InMemoryReservedTurnStore }
|
options?: {
|
||||||
|
reservedTurns?: InMemoryReservedTurnStore;
|
||||||
|
turnDaemonLease?: DatabaseTurnDaemonLease;
|
||||||
|
}
|
||||||
): Promise<DatabaseTurnHooks> => {
|
): Promise<DatabaseTurnHooks> => {
|
||||||
// 턴 처리 결과를 DB에 반영하는 훅을 만든다.
|
// 턴 처리 결과를 DB에 반영하는 훅을 만든다.
|
||||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||||
@@ -355,6 +359,10 @@ export const createDatabaseTurnHooks = async (
|
|||||||
meta: asJson(state.meta),
|
meta: asJson(state.meta),
|
||||||
};
|
};
|
||||||
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
|
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;
|
let neutralAuctionsToCreate = pendingNeutralAuctions;
|
||||||
if (pendingNeutralAuctions.length > 0) {
|
if (pendingNeutralAuctions.length > 0) {
|
||||||
const latestRegistrationKey =
|
const latestRegistrationKey =
|
||||||
|
|||||||
@@ -912,6 +912,39 @@ export class InMemoryTurnWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const nationId of collapsedNationIds) {
|
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);
|
const nation = this.nations.get(nationId);
|
||||||
if (nation) {
|
if (nation) {
|
||||||
const generalIds = Array.from(this.generals.values())
|
const generalIds = Array.from(this.generals.values())
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
|||||||
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
||||||
import { createYearbookHandler } from './yearbookHandler.js';
|
import { createYearbookHandler } from './yearbookHandler.js';
|
||||||
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from './monthlyEventHandler.js';
|
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from './monthlyEventHandler.js';
|
||||||
|
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||||
|
|
||||||
export interface TurnDaemonRuntimeOptions {
|
export interface TurnDaemonRuntimeOptions {
|
||||||
profile: string;
|
profile: string;
|
||||||
@@ -56,6 +57,9 @@ export interface TurnDaemonRuntimeOptions {
|
|||||||
adminActionIntervalMs?: number;
|
adminActionIntervalMs?: number;
|
||||||
redisUrl?: string;
|
redisUrl?: string;
|
||||||
commandStreamStartId?: string;
|
commandStreamStartId?: string;
|
||||||
|
leaseDurationMs?: number;
|
||||||
|
leaseOwnerId?: string;
|
||||||
|
enableLeaseHeartbeat?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnDaemonRuntime {
|
export interface TurnDaemonRuntime {
|
||||||
@@ -89,7 +93,11 @@ const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.
|
|||||||
return resolveRedisConfigFromEnv(env);
|
return resolveRedisConfigFromEnv(env);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions): Promise<TurnDaemonRuntime> => {
|
const createTurnDaemonRuntimeWithLease = async (
|
||||||
|
options: TurnDaemonRuntimeOptions,
|
||||||
|
databaseFlushEnabled: boolean,
|
||||||
|
turnDaemonLease: DatabaseTurnDaemonLease | null
|
||||||
|
): Promise<TurnDaemonRuntime> => {
|
||||||
// DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다.
|
// DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다.
|
||||||
const { state, snapshot } = await loadTurnWorldFromDatabase({
|
const { state, snapshot } = await loadTurnWorldFromDatabase({
|
||||||
databaseUrl: options.databaseUrl,
|
databaseUrl: options.databaseUrl,
|
||||||
@@ -135,7 +143,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
);
|
);
|
||||||
const eventActions = new Map<string, MonthlyEventActionHandler>();
|
const eventActions = new Map<string, MonthlyEventActionHandler>();
|
||||||
eventActions.set('ProcessIncome', (_args, environment) => {
|
eventActions.set('ProcessIncome', (_args, environment) => {
|
||||||
incomeHandler.onMonthChanged?.({
|
void incomeHandler.onMonthChanged?.({
|
||||||
previousYear: environment.month === 1 ? environment.year - 1 : environment.year,
|
previousYear: environment.month === 1 ? environment.year - 1 : environment.year,
|
||||||
previousMonth: environment.month === 1 ? 12 : environment.month - 1,
|
previousMonth: environment.month === 1 ? 12 : environment.month - 1,
|
||||||
currentYear: environment.year,
|
currentYear: environment.year,
|
||||||
@@ -295,9 +303,10 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
if (gatewayGate) {
|
if (gatewayGate) {
|
||||||
pauseGate = gatewayGate.shouldPause;
|
pauseGate = gatewayGate.shouldPause;
|
||||||
}
|
}
|
||||||
if (options.enableDatabaseFlush ?? true) {
|
if (databaseFlushEnabled) {
|
||||||
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
|
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
|
||||||
reservedTurns: reservedTurnStoreHandle?.store,
|
reservedTurns: reservedTurnStoreHandle?.store,
|
||||||
|
turnDaemonLease: turnDaemonLease ?? undefined,
|
||||||
});
|
});
|
||||||
auctionBidder = await createAuctionBidder({
|
auctionBidder = await createAuctionBidder({
|
||||||
databaseUrl: options.databaseUrl,
|
databaseUrl: options.databaseUrl,
|
||||||
@@ -334,6 +343,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
}
|
}
|
||||||
await gatewayGate?.close();
|
await gatewayGate?.close();
|
||||||
await adminActionConsumer?.stop();
|
await adminActionConsumer?.stop();
|
||||||
|
await turnDaemonLease?.close();
|
||||||
};
|
};
|
||||||
} else if (reservedTurnStoreHandle) {
|
} else if (reservedTurnStoreHandle) {
|
||||||
hooks = {
|
hooks = {
|
||||||
@@ -436,7 +446,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
stateStore,
|
stateStore,
|
||||||
processor,
|
processor,
|
||||||
hooks,
|
hooks,
|
||||||
pauseGate,
|
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
|
||||||
commandHandler,
|
commandHandler,
|
||||||
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
|
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
|
||||||
},
|
},
|
||||||
@@ -484,3 +494,25 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
close,
|
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)!;
|
const strongFrontCity = cities.find((city) => city.id === strongFrontCityId)!;
|
||||||
strongFrontCity.frontState = 1;
|
strongFrontCity.frontState = 1;
|
||||||
strongFrontCity.supplyState = 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 = {
|
const unitSet: UnitSetDefinition = {
|
||||||
id: 'test_unit_set',
|
id: 'test_unit_set',
|
||||||
@@ -134,15 +138,22 @@ describe('도시 점령 시 국가 멸망 처리', () => {
|
|||||||
const generals: TurnGeneral[] = [strongLeader];
|
const generals: TurnGeneral[] = [strongLeader];
|
||||||
for (let i = 2; i <= 6; i += 1) {
|
for (let i = 2; i <= 6; i += 1) {
|
||||||
generals.push(
|
generals.push(
|
||||||
createNpcGeneral(i, strongFrontCityId, 1, 2, { leadership: 80, strength: 80, intelligence: 50 }, {
|
createNpcGeneral(
|
||||||
crew: 8000,
|
i,
|
||||||
crewTypeId: 1100,
|
strongFrontCityId,
|
||||||
train: 100,
|
1,
|
||||||
atmos: 100,
|
2,
|
||||||
gold: 100000,
|
{ leadership: 80, strength: 80, intelligence: 50 },
|
||||||
rice: 100000,
|
{
|
||||||
turnTime: delayedTurnTime,
|
crew: 8000,
|
||||||
})
|
crewTypeId: 1100,
|
||||||
|
train: 100,
|
||||||
|
atmos: 100,
|
||||||
|
gold: 100000,
|
||||||
|
rice: 100000,
|
||||||
|
turnTime: delayedTurnTime,
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const weakGeneral = createNpcGeneral(
|
const weakGeneral = createNpcGeneral(
|
||||||
@@ -253,6 +264,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
|
|||||||
expect(world.getCityById(weakCityId)?.nationId).toBe(1);
|
expect(world.getCityById(weakCityId)?.nationId).toBe(1);
|
||||||
expect(world.getNationById(2)).toBeNull();
|
expect(world.getNationById(2)).toBeNull();
|
||||||
expect(world.listNations().some((nation) => nation.id === 2)).toBe(false);
|
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);
|
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
|
||||||
expect(updatedWeakGeneral?.nationId).toBe(0);
|
expect(updatedWeakGeneral?.nationId).toBe(0);
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
|
|||||||
capitalCityId: nation.capitalCityId,
|
capitalCityId: nation.capitalCityId,
|
||||||
cityCount: nationCities.length,
|
cityCount: nationCities.length,
|
||||||
generalCount: nationGenerals.length,
|
generalCount: nationGenerals.length,
|
||||||
|
chiefCount: nationGenerals.filter((general) => general.officerLevel === 12).length,
|
||||||
gold: nation.gold,
|
gold: nation.gold,
|
||||||
rice: nation.rice,
|
rice: nation.rice,
|
||||||
avgGold,
|
avgGold,
|
||||||
@@ -135,12 +136,18 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
|
|||||||
cityCount: cities.length,
|
cityCount: cities.length,
|
||||||
generalCount: generals.length,
|
generalCount: generals.length,
|
||||||
nationStats,
|
nationStats,
|
||||||
|
diplomacy: world.listDiplomacy().map((entry) => ({
|
||||||
|
fromNationId: entry.fromNationId,
|
||||||
|
toNationId: entry.toNationId,
|
||||||
|
state: entry.state,
|
||||||
|
term: entry.term,
|
||||||
|
})),
|
||||||
citySummary,
|
citySummary,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
||||||
it('건국, 점령 완료, 장기 감소 및 통일까지 진행되어야 한다', async () => {
|
it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => {
|
||||||
const cities = buildLargeTestCities().map(maxCityStats);
|
const cities = buildLargeTestCities().map(maxCityStats);
|
||||||
for (const city of cities) {
|
for (const city of cities) {
|
||||||
city.nationId = 0;
|
city.nationId = 0;
|
||||||
@@ -195,9 +202,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
const generals: TurnGeneral[] = [];
|
const generals: TurnGeneral[] = [];
|
||||||
for (let i = 0; i < 300; i += 1) {
|
for (let i = 0; i < 300; i += 1) {
|
||||||
const cityId = cities[i % cities.length]!.id;
|
const cityId = cities[i % cities.length]!.id;
|
||||||
const stats = i % 2 === 0
|
const stats =
|
||||||
? { leadership: 75, strength: 75, intelligence: 10 }
|
i % 2 === 0
|
||||||
: { leadership: 75, strength: 10, intelligence: 75 };
|
? { leadership: 75, strength: 75, intelligence: 10 }
|
||||||
|
: { leadership: 75, strength: 10, intelligence: 75 };
|
||||||
generals.push(createNpcGeneral(i + 1, cityId, stats));
|
generals.push(createNpcGeneral(i + 1, cityId, stats));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,27 +284,48 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
|
|
||||||
const lastNationAiState = new Map<number, unknown>();
|
const lastNationAiState = new Map<number, unknown>();
|
||||||
let declarationCount = 0;
|
let declarationCount = 0;
|
||||||
|
let sortieCount = 0;
|
||||||
|
let lastResolvedAction = 'none';
|
||||||
|
|
||||||
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } = await createTurnTestHarness({
|
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } =
|
||||||
snapshot,
|
await createTurnTestHarness({
|
||||||
state,
|
snapshot,
|
||||||
schedule,
|
state,
|
||||||
map: LARGE_TEST_MAP,
|
schedule,
|
||||||
worldRef,
|
map: LARGE_TEST_MAP,
|
||||||
extraCalendarHandlers: [unificationHandler],
|
worldRef,
|
||||||
collectLogs: true,
|
extraCalendarHandlers: [unificationHandler],
|
||||||
onActionResolved: (payload) => {
|
collectLogs: true,
|
||||||
if (payload.kind !== 'nation') {
|
onActionResolved: (payload) => {
|
||||||
return;
|
const currentWorld = worldRef.current;
|
||||||
}
|
if (currentWorld) {
|
||||||
if (payload.nationId) {
|
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
|
||||||
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
|
const orphanCities = currentWorld
|
||||||
}
|
.listCities()
|
||||||
if (payload.actionKey === 'che_선전포고') {
|
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
|
||||||
declarationCount += 1;
|
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;
|
let monthlyLogCursor = 0;
|
||||||
const maxMonthlyLogEntries = 20;
|
const maxMonthlyLogEntries = 20;
|
||||||
@@ -325,8 +354,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runUntil((current) =>
|
await runUntil(
|
||||||
current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
|
(current) => current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
const world = worldRef.current;
|
const world = worldRef.current;
|
||||||
@@ -336,9 +365,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
|
|
||||||
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
|
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
|
||||||
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
|
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const foundedNationCount = foundedNations.length;
|
||||||
|
|
||||||
await runUntil((current) =>
|
await runUntil(
|
||||||
current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
|
(current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
|
||||||
);
|
);
|
||||||
|
|
||||||
const neutralCities = world.listCities().filter((city) => city.nationId <= 0);
|
const neutralCities = world.listCities().filter((city) => city.nationId <= 0);
|
||||||
@@ -350,8 +380,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
expect(hasHighOfficer).toBe(true);
|
expect(hasHighOfficer).toBe(true);
|
||||||
|
|
||||||
if (declarationCount === 0) {
|
if (declarationCount === 0) {
|
||||||
await runUntil((current) =>
|
await runUntil(
|
||||||
current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
|
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
|
||||||
);
|
);
|
||||||
if (declarationCount === 0) {
|
if (declarationCount === 0) {
|
||||||
const generals = world.listGenerals().filter((general) => general.nationId > 0);
|
const generals = world.listGenerals().filter((general) => general.nationId > 0);
|
||||||
@@ -364,7 +394,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
.filter((nation) => nation.level > 0)
|
.filter((nation) => nation.level > 0)
|
||||||
.map((nation) => {
|
.map((nation) => {
|
||||||
const nationGenerals = generals.filter((general) => general.nationId === nation.id);
|
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 {
|
return {
|
||||||
nationId: nation.id,
|
nationId: nation.id,
|
||||||
totalGenerals: nationGenerals.length,
|
totalGenerals: nationGenerals.length,
|
||||||
@@ -388,10 +420,11 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
const frontStatus = world
|
const frontStatus = world
|
||||||
.listCities()
|
.listCities()
|
||||||
.some(
|
.some(
|
||||||
(city) =>
|
(city) => city.nationId === nation.id && city.supplyState > 0 && city.frontState > 0
|
||||||
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 meta = (nation.meta ?? {}) as Record<string, unknown>;
|
||||||
const lastAttackable = typeof meta.last_attackable === 'number' ? meta.last_attackable : 0;
|
const lastAttackable = typeof meta.last_attackable === 'number' ? meta.last_attackable : 0;
|
||||||
return {
|
return {
|
||||||
@@ -445,18 +478,33 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
|
|
||||||
let prevNationCount = world
|
let prevNationCount = world
|
||||||
.listNations()
|
.listNations()
|
||||||
.filter((nation) => nation.level > 0 && world.listCities().some((city) => city.nationId === nation.id))
|
.filter(
|
||||||
.length;
|
(nation) => nation.level > 0 && world.listCities().some((city) => city.nationId === nation.id)
|
||||||
|
).length;
|
||||||
let unifiedAt: { year: number; month: number } | null = null;
|
let unifiedAt: { year: number; month: number } | null = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const target = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
|
const target = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
|
||||||
await runUntil((current) =>
|
await runUntil(
|
||||||
current.currentYear > target.year ||
|
(current) =>
|
||||||
(current.currentYear === target.year && current.currentMonth >= target.month)
|
current.currentYear > target.year ||
|
||||||
|
(current.currentYear === target.year && current.currentMonth >= target.month)
|
||||||
);
|
);
|
||||||
//_dumpMonthlyLogs(`${target.year}-${String(target.month).padStart(2, '0')}`);
|
//_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
|
const activeNationCount = world
|
||||||
.listNations()
|
.listNations()
|
||||||
.filter((nation) => nation.level > 0)
|
.filter((nation) => nation.level > 0)
|
||||||
@@ -470,37 +518,34 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
|
|
||||||
if (activeNationCount === 1 && !unifiedAt) {
|
if (activeNationCount === 1 && !unifiedAt) {
|
||||||
const nextMonth = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
|
const nextMonth = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
|
||||||
await runUntil((current) =>
|
await runUntil(
|
||||||
current.currentYear > nextMonth.year ||
|
(current) =>
|
||||||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
|
current.currentYear > nextMonth.year ||
|
||||||
|
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
|
||||||
);
|
);
|
||||||
unifiedAt = nextMonth;
|
unifiedAt = nextMonth;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
world.getState().currentYear > 300 ||
|
world.getState().currentYear > 260 ||
|
||||||
(world.getState().currentYear === 300 && world.getState().currentMonth >= 1)
|
(world.getState().currentYear === 260 && world.getState().currentMonth >= 1)
|
||||||
) {
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = world.getState().meta as Record<string, unknown>;
|
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 logs = getCollectedLogs();
|
||||||
const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
|
const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
|
||||||
expect(hasUnificationLog).toBe(true);
|
if (unifiedAt) {
|
||||||
|
expect(meta.isUnited).toBe(2);
|
||||||
const warStates = world.listDiplomacy().filter((entry) => entry.state === DIPLOMACY_STATE.WAR);
|
expect(hasUnificationLog).toBe(true);
|
||||||
if (warStates.length > 0) {
|
} else {
|
||||||
expect(warStates.length).toBeGreaterThan(0);
|
expect(prevNationCount).toBeLessThan(foundedNationCount);
|
||||||
|
expect(meta.isUnited ?? 0).toBe(0);
|
||||||
}
|
}
|
||||||
|
expect(sortieCount).toBeGreaterThan(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const world = worldRef.current;
|
const world = worldRef.current;
|
||||||
if (world) {
|
if (world) {
|
||||||
@@ -508,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
}
|
}
|
||||||
throw error;
|
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
@@ -61,26 +61,27 @@ environment-dependent check.
|
|||||||
|
|
||||||
### `app/game-engine`
|
### `app/game-engine`
|
||||||
|
|
||||||
| Test source | Disposition | Layer | What it establishes |
|
| Test source | Disposition | Layer | What it establishes |
|
||||||
| ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
|
| `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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
|
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
|
||||||
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
|
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
|
||||||
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
|
| `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`
|
### `packages/logic`
|
||||||
|
|
||||||
@@ -118,12 +119,17 @@ environment-dependent check.
|
|||||||
|
|
||||||
### `tools/integration-tests`
|
### `tools/integration-tests`
|
||||||
|
|
||||||
| Test source | Disposition | Layer | What it establishes |
|
| 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/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/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/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/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
|
## Shared test support
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ model InputEvent {
|
|||||||
@@map("input_event")
|
@@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 {
|
model WorldState {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
scenarioCode String @map("scenario_code")
|
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")
|
||||||
|
);
|
||||||
@@ -31,4 +31,5 @@ export interface DatabaseClient {
|
|||||||
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
||||||
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
|
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
|
||||||
inputEvent: GamePrisma.InputEventDelegate;
|
inputEvent: GamePrisma.InputEventDelegate;
|
||||||
|
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,16 +105,22 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
|
|||||||
nation.meta.tech = round(tech);
|
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];
|
const rawConflict = city.meta[META_CONFLICT];
|
||||||
if (!rawConflict) {
|
if (!rawConflict) {
|
||||||
return attackerNationId;
|
return attackerNationId;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(String(rawConflict)) as Record<string, number>;
|
const parsed = JSON.parse(String(rawConflict)) as Record<string, number>;
|
||||||
|
const activeNationIds = new Set(nations.map((nation) => nation.id));
|
||||||
const entries = Object.entries(parsed)
|
const entries = Object.entries(parsed)
|
||||||
.map(([key, value]) => [Number(key), value] as const)
|
.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);
|
.sort(([, lhs], [, rhs]) => rhs - lhs);
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
return attackerNationId;
|
return attackerNationId;
|
||||||
@@ -183,7 +189,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
|||||||
const affectedGenerals = new Set<General<TriggerState>>();
|
const affectedGenerals = new Set<General<TriggerState>>();
|
||||||
const affectedNations = new Set<Nation>();
|
const affectedNations = new Set<Nation>();
|
||||||
|
|
||||||
const conquerNationId = resolveConquerNation(defenderCity, attackerNation.id);
|
const conquerNationId = resolveConquerNation(defenderCity, attackerNation.id, input.nations);
|
||||||
const attackerLogger = new ActionLogger({
|
const attackerLogger = new ActionLogger({
|
||||||
generalId: attacker.id,
|
generalId: attacker.id,
|
||||||
nationId: attackerNation.id,
|
nationId: attackerNation.id,
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ describe('war aftermath', () => {
|
|||||||
defenderNation.rice = 6000;
|
defenderNation.rice = 6000;
|
||||||
const attackerCity = buildCity(1, 1);
|
const attackerCity = buildCity(1, 1);
|
||||||
const defenderCity = buildCity(2, 2);
|
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 attacker = buildGeneral(1, 1, 1);
|
||||||
const defender = buildGeneral(2, 2, 2);
|
const defender = buildGeneral(2, 2, 2);
|
||||||
|
|
||||||
@@ -235,6 +235,8 @@ describe('war aftermath', () => {
|
|||||||
expect(attackerNation.rice).toBe(4600);
|
expect(attackerNation.rice).toBe(4600);
|
||||||
expect(defender.experience).toBe(90);
|
expect(defender.experience).toBe(90);
|
||||||
expect(defender.dedication).toBe(50);
|
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.nationId).toBe(attackerNation.id);
|
||||||
expect(defenderCity.meta.conflict).toBe('{}');
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,5 +4,5 @@
|
|||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["test/**/*.ts"]
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user