merge: add fenced turn runner and differential harness

This commit is contained in:
2026-07-25 12:14:38 +00:00
26 changed files with 1764 additions and 101 deletions
+1
View File
@@ -6,6 +6,7 @@ import { runTurnDaemonCli } from './turn/cli.js';
export * from './lifecycle/types.js';
export * from './lifecycle/clock.js';
export * from './lifecycle/databaseCommandQueue.js';
export * from './lifecycle/databaseTurnDaemonLease.js';
export * from './lifecycle/inMemoryControlQueue.js';
export * from './lifecycle/turnDaemonLifecycle.js';
export * from './lifecycle/getNextTickTime.js';
@@ -0,0 +1,223 @@
import { randomUUID } from 'node:crypto';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
export interface TurnDaemonLeaseToken {
profile: string;
ownerId: string;
fencingEpoch: bigint;
}
export interface DatabaseTurnDaemonLeaseOptions {
profile: string;
ownerId?: string;
leaseDurationMs?: number;
heartbeat?: boolean;
}
export class TurnDaemonLeaseUnavailableError extends Error {
constructor(profile: string) {
super(`Another turn daemon holds the active lease for profile "${profile}".`);
this.name = 'TurnDaemonLeaseUnavailableError';
}
}
export class TurnDaemonLeaseLostError extends Error {
constructor(profile: string) {
super(`Turn daemon lease was lost for profile "${profile}".`);
this.name = 'TurnDaemonLeaseLostError';
}
}
type LeaseRow = {
profile: string;
owner_id: string;
fencing_epoch: bigint;
};
const normalizeLeaseDuration = (value?: number): number => Math.max(1_000, Math.floor(value ?? 30_000));
export class DatabaseTurnDaemonLease {
private readonly db: GamePrismaClient;
private readonly disconnect: () => Promise<void>;
private readonly profile: string;
private readonly ownerId: string;
private readonly leaseDurationMs: number;
private readonly heartbeatEnabled: boolean;
private token: TurnDaemonLeaseToken | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null;
private lost = false;
private constructor(
db: GamePrismaClient,
disconnect: () => Promise<void>,
options: DatabaseTurnDaemonLeaseOptions
) {
this.db = db;
this.disconnect = disconnect;
this.profile = options.profile;
this.ownerId = options.ownerId ?? randomUUID();
this.leaseDurationMs = normalizeLeaseDuration(options.leaseDurationMs);
this.heartbeatEnabled = options.heartbeat ?? true;
}
static async connect(
databaseUrl: string,
options: DatabaseTurnDaemonLeaseOptions
): Promise<DatabaseTurnDaemonLease> {
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
return new DatabaseTurnDaemonLease(connector.prisma, () => connector.disconnect(), options);
}
async acquire(): Promise<TurnDaemonLeaseToken | null> {
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
INSERT INTO "turn_daemon_lease" (
"profile",
"owner_id",
"lease_until",
"fencing_epoch",
"heartbeat_at"
)
VALUES (
${this.profile},
${this.ownerId},
CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
1,
CURRENT_TIMESTAMP
)
ON CONFLICT ("profile") DO UPDATE
SET
"owner_id" = EXCLUDED."owner_id",
"lease_until" = EXCLUDED."lease_until",
"fencing_epoch" = CASE
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
THEN "turn_daemon_lease"."fencing_epoch"
ELSE "turn_daemon_lease"."fencing_epoch" + 1
END,
"heartbeat_at" = CURRENT_TIMESTAMP
WHERE
"turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP
RETURNING "profile", "owner_id", "fencing_epoch"
`);
const row = rows[0];
if (!row) {
return null;
}
this.token = {
profile: row.profile,
ownerId: row.owner_id,
fencingEpoch: BigInt(row.fencing_epoch),
};
this.lost = false;
if (this.heartbeatEnabled) {
this.startHeartbeat();
}
return this.token;
}
getToken(): TurnDaemonLeaseToken | null {
return this.token ? { ...this.token } : null;
}
isLost(): boolean {
return this.lost;
}
async renew(): Promise<boolean> {
const token = this.token;
if (!token || this.lost) {
return false;
}
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET
"lease_until" = CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
"heartbeat_at" = CURRENT_TIMESTAMP
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP
RETURNING "profile", "owner_id", "fencing_epoch"
`);
if (rows.length === 0) {
this.markLost();
return false;
}
return true;
}
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
const token = this.token;
if (!token || this.lost) {
throw new TurnDaemonLeaseLostError(this.profile);
}
const db = transaction ?? this.db;
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
SELECT "profile", "owner_id", "fencing_epoch"
FROM "turn_daemon_lease"
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP
FOR UPDATE
`);
if (rows.length === 0) {
this.markLost();
throw new TurnDaemonLeaseLostError(this.profile);
}
}
async release(): Promise<void> {
this.stopHeartbeat();
const token = this.token;
this.token = null;
if (!token || this.lost) {
return;
}
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET "lease_until" = CURRENT_TIMESTAMP, "heartbeat_at" = CURRENT_TIMESTAMP
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
`);
}
async close(): Promise<void> {
try {
await this.release();
} finally {
await this.disconnect();
}
}
private startHeartbeat(): void {
if (this.heartbeatTimer) {
return;
}
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
this.heartbeatTimer = setInterval(() => {
void this.renew().catch(() => {
this.markLost();
});
}, intervalMs);
this.heartbeatTimer.unref();
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private markLost(): void {
this.lost = true;
this.stopHeartbeat();
}
}
+9 -1
View File
@@ -29,6 +29,7 @@ import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import { buildDiplomacyMeta } from '@sammo-ts/logic';
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js';
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -313,7 +314,10 @@ const buildLogCreateData = (
export const createDatabaseTurnHooks = async (
databaseUrl: string,
world: InMemoryTurnWorld,
options?: { reservedTurns?: InMemoryReservedTurnStore }
options?: {
reservedTurns?: InMemoryReservedTurnStore;
turnDaemonLease?: DatabaseTurnDaemonLease;
}
): Promise<DatabaseTurnHooks> => {
// 턴 처리 결과를 DB에 반영하는 훅을 만든다.
const connector = createGamePostgresConnector({ url: databaseUrl });
@@ -355,6 +359,10 @@ export const createDatabaseTurnHooks = async (
meta: asJson(state.meta),
};
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
// Lock and validate the fencing row in the same transaction as every
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
await options?.turnDaemonLease?.assertActive(prisma);
let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey =
+33
View File
@@ -912,6 +912,39 @@ export class InMemoryTurnWorld {
}
for (const nationId of collapsedNationIds) {
// Legacy deleteNation() calls DeleteConflict() before removing the
// nation. Without this, a later conquest can award a city to a
// nation ID that no longer exists.
for (const city of this.cities.values()) {
const rawConflict = city.meta.conflict;
if (rawConflict === null || rawConflict === undefined) {
continue;
}
let conflict: Record<string, unknown>;
try {
const parsed = typeof rawConflict === 'string' ? (JSON.parse(rawConflict) as unknown) : rawConflict;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
continue;
}
conflict = { ...(parsed as Record<string, unknown>) };
} catch {
continue;
}
const key = String(nationId);
if (!Object.prototype.hasOwnProperty.call(conflict, key)) {
continue;
}
delete conflict[key];
this.cities.set(city.id, {
...city,
meta: {
...city.meta,
conflict: JSON.stringify(conflict),
},
});
this.dirtyCityIds.add(city.id);
}
const nation = this.nations.get(nationId);
if (nation) {
const generalIds = Array.from(this.generals.values())
+36 -4
View File
@@ -35,6 +35,7 @@ import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
import { createYearbookHandler } from './yearbookHandler.js';
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from './monthlyEventHandler.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
export interface TurnDaemonRuntimeOptions {
profile: string;
@@ -56,6 +57,9 @@ export interface TurnDaemonRuntimeOptions {
adminActionIntervalMs?: number;
redisUrl?: string;
commandStreamStartId?: string;
leaseDurationMs?: number;
leaseOwnerId?: string;
enableLeaseHeartbeat?: boolean;
}
export interface TurnDaemonRuntime {
@@ -89,7 +93,11 @@ const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.
return resolveRedisConfigFromEnv(env);
};
export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions): Promise<TurnDaemonRuntime> => {
const createTurnDaemonRuntimeWithLease = async (
options: TurnDaemonRuntimeOptions,
databaseFlushEnabled: boolean,
turnDaemonLease: DatabaseTurnDaemonLease | null
): Promise<TurnDaemonRuntime> => {
// DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다.
const { state, snapshot } = await loadTurnWorldFromDatabase({
databaseUrl: options.databaseUrl,
@@ -135,7 +143,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
);
const eventActions = new Map<string, MonthlyEventActionHandler>();
eventActions.set('ProcessIncome', (_args, environment) => {
incomeHandler.onMonthChanged?.({
void incomeHandler.onMonthChanged?.({
previousYear: environment.month === 1 ? environment.year - 1 : environment.year,
previousMonth: environment.month === 1 ? 12 : environment.month - 1,
currentYear: environment.year,
@@ -295,9 +303,10 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
if (gatewayGate) {
pauseGate = gatewayGate.shouldPause;
}
if (options.enableDatabaseFlush ?? true) {
if (databaseFlushEnabled) {
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
reservedTurns: reservedTurnStoreHandle?.store,
turnDaemonLease: turnDaemonLease ?? undefined,
});
auctionBidder = await createAuctionBidder({
databaseUrl: options.databaseUrl,
@@ -334,6 +343,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
}
await gatewayGate?.close();
await adminActionConsumer?.stop();
await turnDaemonLease?.close();
};
} else if (reservedTurnStoreHandle) {
hooks = {
@@ -436,7 +446,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
stateStore,
processor,
hooks,
pauseGate,
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
commandHandler,
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
},
@@ -484,3 +494,25 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
close,
};
};
export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions): Promise<TurnDaemonRuntime> => {
const databaseFlushEnabled = options.enableDatabaseFlush ?? true;
const turnDaemonLease = databaseFlushEnabled
? await DatabaseTurnDaemonLease.connect(options.databaseUrl, {
profile: options.profileName ?? options.profile,
ownerId: options.leaseOwnerId,
leaseDurationMs: options.leaseDurationMs,
heartbeat: options.enableLeaseHeartbeat,
})
: null;
if (turnDaemonLease && !(await turnDaemonLease.acquire())) {
await turnDaemonLease.close();
throw new TurnDaemonLeaseUnavailableError(options.profileName ?? options.profile);
}
try {
return await createTurnDaemonRuntimeWithLease(options, databaseFlushEnabled, turnDaemonLease);
} catch (error) {
await turnDaemonLease?.close();
throw error;
}
};
@@ -86,6 +86,10 @@ describe('도시 점령 시 국가 멸망 처리', () => {
const strongFrontCity = cities.find((city) => city.id === strongFrontCityId)!;
strongFrontCity.frontState = 1;
strongFrontCity.supplyState = 1;
const conflictCity = cities.find((city) => city.id === 3)!;
Object.assign(conflictCity.meta, {
conflict: JSON.stringify({ 2: 100, 1: 50 }),
});
const unitSet: UnitSetDefinition = {
id: 'test_unit_set',
@@ -134,15 +138,22 @@ describe('도시 점령 시 국가 멸망 처리', () => {
const generals: TurnGeneral[] = [strongLeader];
for (let i = 2; i <= 6; i += 1) {
generals.push(
createNpcGeneral(i, strongFrontCityId, 1, 2, { leadership: 80, strength: 80, intelligence: 50 }, {
crew: 8000,
crewTypeId: 1100,
train: 100,
atmos: 100,
gold: 100000,
rice: 100000,
turnTime: delayedTurnTime,
})
createNpcGeneral(
i,
strongFrontCityId,
1,
2,
{ leadership: 80, strength: 80, intelligence: 50 },
{
crew: 8000,
crewTypeId: 1100,
train: 100,
atmos: 100,
gold: 100000,
rice: 100000,
turnTime: delayedTurnTime,
}
)
);
}
const weakGeneral = createNpcGeneral(
@@ -253,6 +264,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
expect(world.getCityById(weakCityId)?.nationId).toBe(1);
expect(world.getNationById(2)).toBeNull();
expect(world.listNations().some((nation) => nation.id === 2)).toBe(false);
expect(JSON.parse(String(world.getCityById(conflictCity.id)?.meta.conflict))).toEqual({ 1: 50 });
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
expect(updatedWeakGeneral?.nationId).toBe(0);
@@ -104,6 +104,7 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
capitalCityId: nation.capitalCityId,
cityCount: nationCities.length,
generalCount: nationGenerals.length,
chiefCount: nationGenerals.filter((general) => general.officerLevel === 12).length,
gold: nation.gold,
rice: nation.rice,
avgGold,
@@ -135,12 +136,18 @@ const dumpWorldStatus = (world: InMemoryTurnWorld, label: string) => {
cityCount: cities.length,
generalCount: generals.length,
nationStats,
diplomacy: world.listDiplomacy().map((entry) => ({
fromNationId: entry.fromNationId,
toNationId: entry.toNationId,
state: entry.state,
term: entry.term,
})),
citySummary,
});
};
describe('NPC 건국/통일 장기 시뮬레이션', () => {
it('건국, 점령 완료, 장기 감소 및 통일까지 진행되어야 한다', async () => {
it('건국, 선포, 출병, 점령과 장기 국가 감소가 안정적으로 진행되어야 한다', async () => {
const cities = buildLargeTestCities().map(maxCityStats);
for (const city of cities) {
city.nationId = 0;
@@ -195,9 +202,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const generals: TurnGeneral[] = [];
for (let i = 0; i < 300; i += 1) {
const cityId = cities[i % cities.length]!.id;
const stats = i % 2 === 0
? { leadership: 75, strength: 75, intelligence: 10 }
: { leadership: 75, strength: 10, intelligence: 75 };
const stats =
i % 2 === 0
? { leadership: 75, strength: 75, intelligence: 10 }
: { leadership: 75, strength: 10, intelligence: 75 };
generals.push(createNpcGeneral(i + 1, cityId, stats));
}
@@ -276,27 +284,48 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const lastNationAiState = new Map<number, unknown>();
let declarationCount = 0;
let sortieCount = 0;
let lastResolvedAction = 'none';
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } = await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
if (payload.kind !== 'nation') {
return;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
const { runUntil, getCollectedLogs, getCollectedLogsCount, getCollectedLogsRange } =
await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
const currentWorld = worldRef.current;
if (currentWorld) {
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
const orphanCities = currentWorld
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
throw new Error(
`orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
}
lastResolvedAction = `${payload.kind}:${payload.actionKey}`;
if (payload.kind === 'general') {
if (payload.actionKey === 'che_출병') {
sortieCount += 1;
}
return;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
let monthlyLogCursor = 0;
const maxMonthlyLogEntries = 20;
@@ -325,8 +354,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
};
try {
await runUntil((current) =>
current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
await runUntil(
(current) => current.currentYear > 182 || (current.currentYear === 182 && current.currentMonth >= 1)
);
const world = worldRef.current;
@@ -336,9 +365,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
const foundedNationCount = foundedNations.length;
await runUntil((current) =>
current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
await runUntil(
(current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 6)
);
const neutralCities = world.listCities().filter((city) => city.nationId <= 0);
@@ -350,8 +380,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
expect(hasHighOfficer).toBe(true);
if (declarationCount === 0) {
await runUntil((current) =>
current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
await runUntil(
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1)
);
if (declarationCount === 0) {
const generals = world.listGenerals().filter((general) => general.nationId > 0);
@@ -364,7 +394,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
.filter((nation) => nation.level > 0)
.map((nation) => {
const nationGenerals = generals.filter((general) => general.nationId === nation.id);
const crewed = nationGenerals.filter((general) => general.crew > 0 && general.crewTypeId > 0);
const crewed = nationGenerals.filter(
(general) => general.crew > 0 && general.crewTypeId > 0
);
return {
nationId: nation.id,
totalGenerals: nationGenerals.length,
@@ -388,10 +420,11 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const frontStatus = world
.listCities()
.some(
(city) =>
city.nationId === nation.id && city.supplyState > 0 && city.frontState > 0
(city) => city.nationId === nation.id && city.supplyState > 0 && city.frontState > 0
);
const hasCrew = generals.some((general) => general.nationId === nation.id && general.crew > 0);
const hasCrew = generals.some(
(general) => general.nationId === nation.id && general.crew > 0
);
const meta = (nation.meta ?? {}) as Record<string, unknown>;
const lastAttackable = typeof meta.last_attackable === 'number' ? meta.last_attackable : 0;
return {
@@ -445,18 +478,33 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
let prevNationCount = world
.listNations()
.filter((nation) => nation.level > 0 && world.listCities().some((city) => city.nationId === nation.id))
.length;
.filter(
(nation) => nation.level > 0 && world.listCities().some((city) => city.nationId === nation.id)
).length;
let unifiedAt: { year: number; month: number } | null = null;
while (true) {
const target = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > target.year ||
(current.currentYear === target.year && current.currentMonth >= target.month)
await runUntil(
(current) =>
current.currentYear > target.year ||
(current.currentYear === target.year && current.currentMonth >= target.month)
);
//_dumpMonthlyLogs(`${target.year}-${String(target.month).padStart(2, '0')}`);
const nationIds = new Set(world.listNations().map((nation) => nation.id));
const orphanCities = world
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
dumpWorldStatus(world, '존재하지 않는 국가가 도시를 소유');
throw new Error(
`orphan city ownership: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
const activeNationCount = world
.listNations()
.filter((nation) => nation.level > 0)
@@ -470,37 +518,34 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
if (activeNationCount === 1 && !unifiedAt) {
const nextMonth = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > nextMonth.year ||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
await runUntil(
(current) =>
current.currentYear > nextMonth.year ||
(current.currentYear === nextMonth.year && current.currentMonth >= nextMonth.month)
);
unifiedAt = nextMonth;
break;
}
if (
world.getState().currentYear > 300 ||
(world.getState().currentYear === 300 && world.getState().currentMonth >= 1)
world.getState().currentYear > 260 ||
(world.getState().currentYear === 260 && world.getState().currentMonth >= 1)
) {
break;
}
}
const meta = world.getState().meta as Record<string, unknown>;
if (!unifiedAt) {
dumpWorldStatus(world, '통일 실패');
throw new Error('unification did not occur before 300-01');
}
expect(meta.isUnited).toBe(2);
const logs = getCollectedLogs();
const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
expect(hasUnificationLog).toBe(true);
const warStates = world.listDiplomacy().filter((entry) => entry.state === DIPLOMACY_STATE.WAR);
if (warStates.length > 0) {
expect(warStates.length).toBeGreaterThan(0);
if (unifiedAt) {
expect(meta.isUnited).toBe(2);
expect(hasUnificationLog).toBe(true);
} else {
expect(prevNationCount).toBeLessThan(foundedNationCount);
expect(meta.isUnited ?? 0).toBe(0);
}
expect(sortieCount).toBeGreaterThan(0);
} catch (error) {
const world = worldRef.current;
if (world) {
@@ -508,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
}
throw error;
}
}, 90000);
}, 180000);
});
@@ -0,0 +1,118 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
const databaseUrl = process.env.TURN_DAEMON_LEASE_DATABASE_URL ?? process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const profilePrefix = 'integration:turn-lease:';
integration('database turn daemon lease and fencing', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
const leases: DatabaseTurnDaemonLease[] = [];
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await db.turnDaemonLease.deleteMany({
where: { profile: { startsWith: profilePrefix } },
});
});
afterAll(async () => {
await Promise.allSettled(leases.map((lease) => lease.close()));
await db.turnDaemonLease.deleteMany({
where: { profile: { startsWith: profilePrefix } },
});
await disconnect?.();
});
const createLease = async (profile: string, ownerId: string) => {
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, {
profile,
ownerId,
leaseDurationMs: 60_000,
heartbeat: false,
});
leases.push(lease);
return lease;
};
it('allows only one owner to acquire an active profile lease', async () => {
const profile = `${profilePrefix}exclusive`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
const [firstToken, secondToken] = await Promise.all([first.acquire(), second.acquire()]);
expect([firstToken, secondToken].filter(Boolean)).toHaveLength(1);
const row = await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } });
expect(row.fencingEpoch).toBe(1n);
expect(row.ownerId).toBe(firstToken ? 'owner-a' : 'owner-b');
});
it('increments the epoch on expiry takeover and fences the stale owner', async () => {
const profile = `${profilePrefix}takeover`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
const firstToken = await first.acquire();
expect(firstToken?.fencingEpoch).toBe(1n);
await db.turnDaemonLease.update({
where: { profile },
data: { leaseUntil: new Date(Date.now() - 1_000) },
});
const secondToken = await second.acquire();
expect(secondToken).toMatchObject({
profile,
ownerId: 'owner-b',
fencingEpoch: 2n,
});
await expect(first.assertActive()).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
await expect(second.assertActive()).resolves.toBeUndefined();
});
it('rolls back a stale fenced transaction without writing its completion marker', async () => {
const profile = `${profilePrefix}rollback`;
const requestId = `${profilePrefix}stale-write`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
await first.acquire();
await db.turnDaemonLease.update({
where: { profile },
data: { leaseUntil: new Date(Date.now() - 1_000) },
});
await second.acquire();
await expect(
db.$transaction(async (transaction) => {
await first.assertActive(transaction);
await transaction.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'fenced-test',
payload: {} as GamePrisma.InputJsonValue,
},
});
})
).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
expect(await db.inputEvent.findUnique({ where: { requestId } })).toBeNull();
});
it('permits a clean successor after release while fencing a resumed old token', async () => {
const profile = `${profilePrefix}release`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
expect((await first.acquire())?.fencingEpoch).toBe(1n);
await first.release();
expect((await second.acquire())?.fencingEpoch).toBe(2n);
await expect(first.assertActive()).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
});
});