merge: add fenced turn runner and differential harness
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user