fix: 가오픈 명령과 GAME WALL 시간 경계 회귀 수정

This commit is contained in:
2026-09-05 00:55:33 +00:00
parent 1d53e6f226
commit 3437cd99f4
25 changed files with 458 additions and 71 deletions
@@ -14,10 +14,8 @@ import {
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
const enabled =
process.env.CLOCK_RECONCILIATION_INTEGRATION === '1' &&
Boolean(process.env.DATABASE_URL) &&
Boolean(process.env.REDIS_URL);
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
const describeIntegration = enabled ? describe : describe.skip;
describeIntegration('durable clock reconciliation', () => {
@@ -47,7 +45,7 @@ describeIntegration('durable clock reconciliation', () => {
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! });
const connector = createGamePostgresConnector({ url: databaseUrl! });
db = connector.prisma;
disconnect = connector.disconnect;
redis = createRedisConnector({ url: process.env.REDIS_URL! });
@@ -225,16 +223,16 @@ describeIntegration('durable clock reconciliation', () => {
const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
await Promise.all([
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
db.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(),
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
db.clockProjectionOutbox.findMany(),
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
db.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(),
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
db.clockProjectionOutbox.findMany(),
]);
const alignedTick = BigInt(reconciled.alignedTick);
expect(afterWorld).toMatchObject({
@@ -121,6 +121,31 @@ integration('database command queue', () => {
});
});
it('leaves pending work immediately claimable by a replacement daemon when shutting down', async () => {
const requestId = 'integration:engine:shutdown-pending';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'dieOnPrestart',
actorUserId: 'user-7',
payload: { type: 'dieOnPrestart', requestId, userId: 'user-7', generalId: 7 },
},
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
queue.enqueue({ type: 'shutdown', reason: 'replacement' });
expect(await queue.drain()).toEqual([{ type: 'shutdown', reason: 'replacement' }]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'PENDING',
attempts: 0,
lockedBy: null,
leaseUntil: null,
});
expect(await new DatabaseTurnDaemonCommandQueue(db).drain()).toMatchObject([
{ type: 'dieOnPrestart', requestId },
]);
});
it('recovers only an expired processing lease', async () => {
const expiredId = 'integration:engine:expired';
const activeId = 'integration:engine:active';
@@ -313,6 +338,55 @@ integration('database command queue', () => {
expect(mutation).not.toHaveBeenCalled();
});
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
'handles pre-opening user commands in %s without treating them as scheduled turns',
async (phase) => {
await db.worldState.updateMany({
data: {
clockPhase: phase,
clockMode: 'realtime',
clockTick: 0n,
lastTurnTick: 0n,
clockWallAnchor: new Date(Date.now() + 3_600_000),
},
});
const types = ['ensureDieOnPrestartStatus', 'dieOnPrestart', 'buildNationCandidate'] as const;
await db.inputEvent.createMany({
data: types.map((type) => {
const requestId = `integration:engine:preopen:${type}`;
return {
requestId,
target: 'ENGINE' as const,
eventType: type,
actorUserId: 'user-7',
payload: { type, requestId, userId: 'user-7', generalId: 7 },
};
}),
});
const commands = await new DatabaseTurnDaemonCommandQueue(db).drain();
const allowed = ['PREOPEN', 'RUNNING', 'MANUAL'].includes(phase);
expect(commands.map((command) => command.type)).toEqual(allowed ? types : []);
const events = await db.inputEvent.findMany({
where: { requestId: { startsWith: 'integration:engine:preopen:' } },
});
expect(events).toHaveLength(3);
for (const event of events) {
expect(event.status).toBe(allowed ? 'PROCESSING' : 'PENDING');
expect(event.attempts).toBe(allowed ? 1 : 0);
expect(event.processingClockRevision).toBe(allowed ? 1n : null);
expect(event.processingDeadlineGeneration).toBe(allowed ? 1n : null);
if (phase === 'PREOPEN') {
expect(event.processingGameTick).toBeLessThan(0n);
}
}
expect(await db.worldState.findFirst()).toMatchObject({
clockPhase: phase,
clockTick: 0n,
lastTurnTick: 0n,
});
}
);
it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld
+22 -1
View File
@@ -4,7 +4,11 @@ import type { GamePrisma } from '@sammo-ts/infra';
import type { TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter } from '../src/turn/prestartDeletion.js';
import {
buildPrestartDeleteAfter,
formatPrestartDeleteAfter,
hasStartedForPrestartActions,
} from '../src/turn/prestartDeletion.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
@@ -59,6 +63,8 @@ const buildFixture = (options: {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'),
lastTurnTick:
options.lastTurnTime && options.lastTurnTime > new Date('2026-08-01T00:00:00.000Z') ? 36_000_000 : 0,
meta: { opentime: '2026-08-01T00:00:00.000Z' },
};
const snapshot: TurnWorldSnapshot = {
@@ -118,6 +124,21 @@ const buildFixture = (options: {
};
describe('pre-start general deletion', () => {
it('uses the opening phase and executed tick despite shifted or ancient display projections', () => {
const state = { lastTurnTime: new Date('2099-01-01T00:00:00Z'), meta: { opentime: '2026-01-01T00:00:00Z' } };
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'PREOPEN', lastTurnTick: 0 })).toBe(false);
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'RUNNING', lastTurnTick: 0 })).toBe(false);
expect(
hasStartedForPrestartActions({
...state,
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
clockPhase: 'RUNNING',
lastTurnTick: 1,
})
).toBe(true);
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'COMPLETED', lastTurnTick: 0 })).toBe(true);
});
it('uses the default two turns, scenario override, and Ref Seoul error timestamp', () => {
expect(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
expect(
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SystemClock } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -97,6 +97,14 @@ const state: TurnWorldState = {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T00:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T00:00:00.000Z'),
clockTick: 0,
lastTurnTick: 0,
clockMode: 'realtime',
clockPhase: 'PREOPEN',
clockWallAnchor: new Date(Date.now() + 86_400_000),
clockRevision: 1,
deadlineGeneration: 1,
meta: {
hiddenSeed: 'immediate-action-integration',
killturn: 24,
@@ -155,8 +163,10 @@ integration('immediate general action persistence', () => {
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
});
await db.inputEvent.deleteMany({ where: { requestId } });
beforeEach(async () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
await db.logEntry.deleteMany({
where: {
@@ -181,6 +191,14 @@ integration('immediate general action persistence', () => {
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
clockBaseTime: state.clockBaseTime,
clockTick: 0n,
lastTurnTick: 0n,
clockMode: state.clockMode,
clockPhase: state.clockPhase,
clockWallAnchor: state.clockWallAnchor,
clockRevision: 1n,
deadlineGeneration: 1n,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
@@ -257,7 +275,7 @@ integration('immediate general action persistence', () => {
await disconnect?.();
return;
}
await db.inputEvent.deleteMany({ where: { requestId } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
await db.logEntry.deleteMany({
where: {
@@ -277,7 +295,7 @@ integration('immediate general action persistence', () => {
await disconnect?.();
});
it('flushes and reloads the nation, diplomacy, officer turns, logs, and general state together', async () => {
it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => {
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [
@@ -376,10 +394,15 @@ integration('immediate general action persistence', () => {
});
const stateStore = {
loadLastTurnTime: async () => new Date(state.lastTurnTime),
loadNextGeneralTurnTime: async () => null,
loadNextGeneralTurnTime: async () => general.turnTime,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({
mode: 'realtime' as const,
phase: 'PREOPEN' as const,
now: world.getGameNow(new Date()),
}),
};
const processor = {
run: async () => {
@@ -597,5 +620,108 @@ integration('immediate general action persistence', () => {
chiefGeneralId: generalId,
rice: 2_000,
});
expect(reloaded.state).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0, lastTurnTick: 0 });
});
it('deletes a neutral general after the wall deadline in PREOPEN and commits its durable result once', async () => {
const cutoff = new Date('2026-07-31T00:20:00.000Z');
await db.general.update({
where: { id: generalId },
data: { meta: { ...general.meta, prestart_delete_after: cutoff.toISOString() } },
});
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { schedule });
const handler = createTurnDaemonCommandHandler({ world });
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (value) => world.restoreState(value),
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
const processor = {
run: vi.fn(async () => {
throw new Error('PREOPEN must not execute scheduled turns');
}),
};
const ids = [':status', ':early', ':delete'].map((suffix) => requestId + suffix);
const types = ['ensureDieOnPrestartStatus', 'dieOnPrestart', 'dieOnPrestart'];
await db.inputEvent.createMany({
data: ids.map((id, index) => ({
requestId: id,
target: 'ENGINE',
eventType: types[index]!,
actorUserId: general.userId,
createdAt: new Date(cutoff.getTime() + (index === 2 ? 0 : -1)),
payload: { type: types[index], requestId: id, userId: general.userId, generalId },
})),
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new SystemClock(),
controlQueue: queue,
commandResponder: queue,
commandHandler: handler,
hooks: hooks.hooks,
stateManager,
processor,
getNextTickTime: () => general.turnTime,
stateStore: {
loadLastTurnTime: async () => state.lastTurnTime,
loadNextGeneralTurnTime: async () => general.turnTime,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({
mode: 'realtime',
phase: 'PREOPEN',
now: world.getGameNow(new Date()),
}),
},
},
{
profile: 'immediate-action-integration',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
const loop = lifecycle.start();
try {
await vi.waitFor(
async () => {
const events = await db.inputEvent.findMany({
where: { requestId: { in: ids } },
orderBy: { sequence: 'asc' },
});
expect(events.map((event) => event.status)).toEqual(['SUCCEEDED', 'SUCCEEDED', 'SUCCEEDED']);
expect(events[0]?.result).toMatchObject({
show: true,
available: false,
availableAt: cutoff.toISOString(),
});
expect(events[1]?.result).toMatchObject({
ok: false,
reason: expect.stringContaining('아직 삭제할 수 없습니다'),
});
expect(events[2]?.result).toMatchObject({ ok: true, generalId });
expect(
events.every((event) => event.processingGameTick !== null && event.processingGameTick < 0n)
).toBe(true);
expect(events.every((event) => event.attempts === 1)).toBe(true);
},
{ timeout: 5_000 }
);
} finally {
await lifecycle.stop('pre-opening deletion checked');
await loop;
await hooks.close();
}
expect(processor.run).not.toHaveBeenCalled();
expect(await queue.drain()).toEqual([]);
expect(await db.general.findUnique({ where: { id: generalId } })).toBeNull();
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((entry) => entry.id === generalId)).toBeUndefined();
expect(reloaded.state).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0, lastTurnTick: 0 });
expect(await db.logEntry.count({ where: { scope: 'SYSTEM', text: { contains: '홀연히 모습을' } } })).toBe(1);
});
});
@@ -169,6 +169,7 @@ const buildImmediateActionWorld = (options: {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: options.lastTurnTime ?? new Date('0180-01-01T00:00:00Z'),
lastTurnTick: options.lastTurnTime && options.lastTurnTime > new Date('0180-02-01T00:00:00Z') ? 36_000_000 : 0,
meta: {
hiddenSeed: 'immediate-action-test',
killturn: 24,
@@ -212,6 +212,24 @@ describe('runtime clock shift', () => {
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
});
it('preserves the formal wall opening when PREOPEN game display dates are shifted', () => {
const openAt = new Date('2026-09-06T00:00:00.000Z');
const now = new Date('2026-09-05T00:00:00.000Z');
const world = buildWorld({
clockBaseTime: new Date('2026-07-30T10:00:00.000Z'),
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: openAt,
lastTurnTick: 0,
clockPhase: 'PREOPEN',
});
world.shiftSchedule(15, now);
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: openAt });
expect(world.promotePreopenAtOpening(now)).toBe(false);
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:15:00.000Z'));
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
});
it('rejects gameplay commits while the durable clock is suspended', async () => {
const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' });
@@ -14,6 +14,41 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => {
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING', 'COMPLETED'] as const)(
'does not dispatch an explicit run while the clock phase is %s',
async (phase) => {
const now = new Date('2026-09-05T00:00:00.000Z');
const controlQueue = new InMemoryControlQueue();
controlQueue.enqueue({ type: 'run', reason: 'manual' });
const processor = { run: vi.fn() };
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue,
processor,
getNextTickTime: (value) => addMinutes(value, 5),
stateStore: {
loadLastTurnTime: async () => now,
loadNextGeneralTurnTime: async () => now,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => {
controlQueue.enqueue({ type: 'shutdown' });
return { mode: 'realtime', phase, now };
},
},
},
{
profile: 'clock-phase-run-gate',
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
}
);
await lifecycle.start();
expect(processor.run).not.toHaveBeenCalled();
}
);
it('durably rebases a long realtime backlog before executing another turn', async () => {
const wallNow = new Date('2026-08-23T01:35:00.000Z');
const clock = new ManualClock(wallNow.getTime());