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/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();
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user