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
@@ -67,6 +67,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
any: { any: {
ban: { expire: 4_102_444_800, value: 1 }, ban: { expire: 4_102_444_800, value: 1 },
expired: { expire: 1, value: 9 }, expired: { expire: 1, value: 9 },
expiredWall: { expire: Date.parse('2026-08-01T00:00:00.000Z') / 1000, value: 9 },
}, },
hwe: { hwe: {
ban: { expire: 4_102_444_800, value: 2 }, ban: { expire: 4_102_444_800, value: 2 },
@@ -149,8 +150,9 @@ integration('generic general creation through the durable turn daemon', () => {
await seedScenarioToDatabase({ await seedScenarioToDatabase({
scenarioId: 2, scenarioId: 2,
databaseUrl: databaseUrl!, databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'), now: new Date('2026-07-30T12:00:00.000Z'),
installOptions: { installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5, turnTermMinutes: 5,
npcMode: 0, npcMode: 0,
showImgLevel: 3, showImgLevel: 3,
@@ -289,6 +291,7 @@ integration('generic general creation through the durable turn daemon', () => {
}, },
}); });
expect(created.affinity).toBeGreaterThanOrEqual(1); expect(created.affinity).toBeGreaterThanOrEqual(1);
expect(created.penalty).toEqual({ ban: 2, chat: 3 });
expect(created.affinity).toBeLessThanOrEqual(150); expect(created.affinity).toBeLessThanOrEqual(150);
expect(created.meta).toMatchObject({ expect(created.meta).toMatchObject({
createdBy: 'join', createdBy: 'join',
@@ -305,6 +308,9 @@ integration('generic general creation through the durable turn daemon', () => {
throw new Error('created general must have an initial access timestamp'); throw new Error('created general must have an initial access timestamp');
} }
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt); expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
expect(created.turnTick).toBeGreaterThanOrEqual(0n);
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
expect( expect(
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() - new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
acceptedEvent.createdAt.getTime() acceptedEvent.createdAt.getTime()
@@ -379,7 +379,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
attempts: 1, attempts: 1,
actorUserId: userId, actorUserId: userId,
}); });
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime()); expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
const logs = await db.logEntry.findMany({ const logs = await db.logEntry.findMany({
where: { where: {
OR: [ OR: [
@@ -671,7 +671,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await db.npcSelectionToken.update({ await db.npcSelectionToken.update({
where: { ownerUserId: rejectedUserId }, where: { ownerUserId: rejectedUserId },
data: { validUntil: new Date('2000-01-01T00:00:00.000Z') }, data: { validUntilTick: -1n },
}); });
await expect( await expect(
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({ appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
@@ -142,8 +142,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await seedScenarioToDatabase({ await seedScenarioToDatabase({
scenarioId: 903, scenarioId: 903,
databaseUrl: databaseUrl!, databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'), now: new Date('2026-07-30T12:00:00.000Z'),
installOptions: { installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5, turnTermMinutes: 5,
npcMode: 2, npcMode: 2,
showImgLevel: 3, showImgLevel: 3,
@@ -274,6 +275,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
status: 'SUCCEEDED', status: 'SUCCEEDED',
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id }, result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
}); });
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
expect(initial.turnTick).toBeGreaterThanOrEqual(0n);
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
if (!initialAccess.lastRefresh) { if (!initialAccess.lastRefresh) {
throw new Error('selected general must have an initial access timestamp'); throw new Error('selected general must have an initial access timestamp');
} }
@@ -33,6 +33,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
async drain(): Promise<TurnDaemonCommand[]> { async drain(): Promise<TurnDaemonCommand[]> {
const local = this.localQueue.splice(0, this.localQueue.length); const local = this.localQueue.splice(0, this.localQueue.length);
// 종료 직후 실행하지 않을 명령을 PROCESSING으로 선점하면 새 daemon은
// lease 만료까지 기다려야 한다. 종료 batch에서는 DB 명령을 가져오지 않는다.
if (local.some((command) => command.type === 'shutdown')) {
return local;
}
const remote = await this.claimPending(); const remote = await this.claimPending();
return local.concat(remote); return local.concat(remote);
} }
@@ -116,7 +121,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true }, select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
}); });
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL'; // 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
const gameplayAllowed =
!world ||
world.clockPhase === 'PREOPEN' ||
world.clockPhase === 'RUNNING' ||
world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED'; const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
const currentRevision = world?.clockRevision ?? null; const currentRevision = world?.clockRevision ?? null;
const maintenanceSuspended = const maintenanceSuspended =
@@ -158,6 +158,20 @@ export class TurnDaemonLifecycle {
continue; continue;
} }
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (gameClock?.phase && gameClock.phase !== 'RUNNING' && gameClock.phase !== 'MANUAL') {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
if (this.pendingRun) { if (this.pendingRun) {
await this.runOnce(this.pendingRun); await this.runOnce(this.pendingRun);
this.pendingRun = null; this.pendingRun = null;
@@ -169,23 +183,6 @@ export class TurnDaemonLifecycle {
await this.clock.sleepMs(200); await this.clock.sleepMs(200);
continue; continue;
} }
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (
gameClock?.phase &&
gameClock.phase !== 'RUNNING' &&
gameClock.phase !== 'MANUAL'
) {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
if (gameClock?.mode === 'manual') { if (gameClock?.mode === 'manual') {
// Ref observes all generals due before one monthly boundary in // Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that // a single snapshot. Manual mode advances directly to that
+5 -1
View File
@@ -1711,7 +1711,11 @@ export class InMemoryTurnWorld {
// Rebasing is also the explicit resume checkpoint. Realtime mode // Rebasing is also the explicit resume checkpoint. Realtime mode
// must not replay the operational downtime after an administrator // must not replay the operational downtime after an administrator
// deliberately delays or accelerates the game schedule. // deliberately delays or accelerates the game schedule.
clockWallAnchor: new Date(wallNow.getTime()), // 가오픈의 anchor는 별도 예약된 정식 오픈이다. 표시 좌표를 옮기는
// 작업이 그 미래 경계를 현재 시각으로 당겨 게임을 시작시키면 안 된다.
clockWallAnchor: new Date(
previousClock.phase === 'PREOPEN' ? previousClock.wallAnchor.getTime() : wallNow.getTime()
),
lastTurnTime: nextLastTurnTime, lastTurnTime: nextLastTurnTime,
meta: nextMeta, meta: nextMeta,
}; };
@@ -96,7 +96,7 @@ export const normalizeJoinSpecialityCode = (value: unknown): string | null =>
export const resolveLegacyPenalty = ( export const resolveLegacyPenalty = (
rawPenalty: Record<string, unknown> | undefined, rawPenalty: Record<string, unknown> | undefined,
profileId: string, profileId: string,
acceptedAt: Date requestedAtWall: Date
): Record<string, unknown> => { ): Record<string, unknown> => {
if (!rawPenalty) { if (!rawPenalty) {
return {}; return {};
@@ -105,7 +105,7 @@ export const resolveLegacyPenalty = (
...asRecord(rawPenalty.any), ...asRecord(rawPenalty.any),
...asRecord(rawPenalty[profileId]), ...asRecord(rawPenalty[profileId]),
}; };
const acceptedAtSeconds = acceptedAt.getTime() / 1000; const acceptedAtSeconds = requestedAtWall.getTime() / 1000;
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
for (const [key, rawEntry] of Object.entries(merged)) { for (const [key, rawEntry] of Object.entries(merged)) {
const entry = asRecord(rawEntry); const entry = asRecord(rawEntry);
@@ -654,12 +654,7 @@ export const createGeneralFromJoin = async (options: {
const generalId = world.getNextGeneralId(); const generalId = world.getNextGeneralId();
const rng = new RandUtil( const rng = new RandUtil(
new LiteHashDRBG( new LiteHashDRBG(
buildJoinCreateGeneralSeed( buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt), generalId)
hiddenSeed,
input.seedOwnerIdentity,
world.dateToGameTick(acceptedAt),
generalId
)
) )
); );
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01); const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
@@ -805,7 +800,9 @@ export const createGeneralFromJoin = async (options: {
meta: {}, meta: {},
}, },
lastTurn: { command: DEFAULT_TURN_ACTION }, lastTurn: { command: DEFAULT_TURN_ACTION },
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, acceptedAt), // 계정 제재 expire는 Ref member의 Unix wall timestamp다. 가오픈의
// 음수 GAME 좌표로 비교하면 현실에서 만료된 제재가 다시 적용된다.
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, operationalAcceptedAt),
inheritancePoints: { inheritancePoints: {
previous: finalInheritancePoint, previous: finalInheritancePoint,
}, },
@@ -1,7 +1,22 @@
import { asNumber, asRecord } from '@sammo-ts/common'; import { asNumber, asRecord } from '@sammo-ts/common';
import type { TurnWorldState } from './types.js';
const DEFAULT_MIN_TURNS = 2; const DEFAULT_MIN_TURNS = 2;
/** 표시용 opentime과 재투영된 lastTurnTime 대신 오픈 phase와 실행 cursor로 판정한다. */
export const hasStartedForPrestartActions = (
state: Pick<TurnWorldState, 'clockPhase' | 'lastTurnTick' | 'lastTurnTime' | 'meta'>
): boolean => {
if (state.clockPhase === 'PREOPEN') return false;
if (state.clockPhase === 'COMPLETED' || state.clockPhase === 'SUSPENDED' || state.clockPhase === 'RECONCILING') {
return true;
}
// Ref의 turntime == opentime 경계는 아직 허용하고 최초 월 진행 이후 닫는다.
if (state.lastTurnTick !== undefined) return state.lastTurnTick > 0;
const opentime = typeof state.meta.opentime === 'string' ? new Date(state.meta.opentime) : null;
return opentime !== null && state.lastTurnTime.getTime() > opentime.getTime();
};
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => { export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
const value = meta.prestart_delete_after; const value = meta.prestart_delete_after;
if (typeof value !== 'string' || !value.trim()) { if (typeof value !== 'string' || !value.trim()) {
+21 -10
View File
@@ -70,7 +70,12 @@ import {
} from './selectPoolService.js'; } from './selectPoolService.js';
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js'; import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js'; import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js'; import {
buildPrestartDeleteAfter,
formatPrestartDeleteAfter,
readPrestartDeleteAfter,
hasStartedForPrestartActions,
} from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js'; import { respondToActionableMessage } from './actionableMessageResponse.js';
import { executeInheritanceAction } from './inheritanceActionService.js'; import { executeInheritanceAction } from './inheritanceActionService.js';
import { applyNpcPolicyMutation } from './npcPolicyMutation.js'; import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
@@ -344,8 +349,14 @@ async function handleJoinCreateGeneral(
throw new Error('Join world state is missing.'); throw new Error('Join world state is missing.');
} }
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt); const processingGameTick = Reflect.get(command, 'processingGameTick');
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt); if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
try { try {
return { return {
type: 'joinCreateGeneral', type: 'joinCreateGeneral',
@@ -463,7 +474,10 @@ async function handleSelectPoolCreate(
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.'); throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
} }
const acceptedAt = ctx.world.gameTickToDate(processingGameTick); const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = acceptedAt; // 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
try { try {
return { return {
type: 'selectPoolCreate', type: 'selectPoolCreate',
@@ -1607,8 +1621,7 @@ async function handleEnsureDieOnPrestartStatus(
const db = requireCommandDatabase(ctx); const db = requireCommandDatabase(ctx);
const acceptedAt = await resolveCommandAcceptedAt(db, command); const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = ctx.world.getState(); const worldState = ctx.world.getState();
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null; if (hasStartedForPrestartActions(worldState) || general.nationId !== 0) {
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
return { return {
type: 'ensureDieOnPrestartStatus', type: 'ensureDieOnPrestartStatus',
generalId: command.generalId, generalId: command.generalId,
@@ -1643,8 +1656,7 @@ async function handleDieOnPrestart(
} }
const acceptedAt = await resolveCommandAcceptedAt(db, command); const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = world.getState(); const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined; if (hasStartedForPrestartActions(worldState)) {
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
return { return {
type: 'dieOnPrestart', type: 'dieOnPrestart',
ok: false, ok: false,
@@ -1709,8 +1721,7 @@ async function handleBuildNationCandidate(
await assertImmediateGeneralActionActor(ctx, command, general); await assertImmediateGeneralActionActor(ctx, command, general);
const worldState = world.getState(); const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined; if (hasStartedForPrestartActions(worldState)) {
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
return { return {
type: 'buildNationCandidate', type: 'buildNationCandidate',
ok: false, ok: false,
@@ -14,10 +14,8 @@ import {
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js'; import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js'; import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
const enabled = const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
process.env.CLOCK_RECONCILIATION_INTEGRATION === '1' && const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
Boolean(process.env.DATABASE_URL) &&
Boolean(process.env.REDIS_URL);
const describeIntegration = enabled ? describe : describe.skip; const describeIntegration = enabled ? describe : describe.skip;
describeIntegration('durable clock reconciliation', () => { describeIntegration('durable clock reconciliation', () => {
@@ -47,7 +45,7 @@ describeIntegration('durable clock reconciliation', () => {
}; };
beforeAll(async () => { beforeAll(async () => {
const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! }); const connector = createGamePostgresConnector({ url: databaseUrl! });
db = connector.prisma; db = connector.prisma;
disconnect = connector.disconnect; disconnect = connector.disconnect;
redis = createRedisConnector({ url: process.env.REDIS_URL! }); 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] = const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
await Promise.all([ await Promise.all([
db.worldState.findUniqueOrThrow({ where: { id: world.id } }), db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
db.general.findMany({ orderBy: { id: 'asc' } }), db.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(), db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(), db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(), db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(), db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(), db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(), db.npcSelectionToken.findFirstOrThrow(),
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }), db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
db.clockProjectionOutbox.findMany(), db.clockProjectionOutbox.findMany(),
]); ]);
const alignedTick = BigInt(reconciled.alignedTick); const alignedTick = BigInt(reconciled.alignedTick);
expect(afterWorld).toMatchObject({ 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 () => { it('recovers only an expired processing lease', async () => {
const expiredId = 'integration:engine:expired'; const expiredId = 'integration:engine:expired';
const activeId = 'integration:engine:active'; const activeId = 'integration:engine:active';
@@ -313,6 +338,55 @@ integration('database command queue', () => {
expect(mutation).not.toHaveBeenCalled(); 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 () => { 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 existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld 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 type { TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; 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 type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
@@ -59,6 +63,8 @@ const buildFixture = (options: {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'), 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' }, meta: { opentime: '2026-08-01T00:00:00.000Z' },
}; };
const snapshot: TurnWorldSnapshot = { const snapshot: TurnWorldSnapshot = {
@@ -118,6 +124,21 @@ const buildFixture = (options: {
}; };
describe('pre-start general deletion', () => { 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', () => { 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(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
expect( 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 { SystemClock } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -97,6 +97,14 @@ const state: TurnWorldState = {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T00:00:00.000Z'), 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: { meta: {
hiddenSeed: 'immediate-action-integration', hiddenSeed: 'immediate-action-integration',
killturn: 24, killturn: 24,
@@ -155,8 +163,10 @@ integration('immediate general action persistence', () => {
await connector.connect(); await connector.connect();
db = connector.prisma; db = connector.prisma;
disconnect = () => connector.disconnect(); 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.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
await db.logEntry.deleteMany({ await db.logEntry.deleteMany({
where: { where: {
@@ -181,6 +191,14 @@ integration('immediate general action persistence', () => {
currentYear: state.currentYear, currentYear: state.currentYear,
currentMonth: state.currentMonth, currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds, 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, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue,
}, },
@@ -257,7 +275,7 @@ integration('immediate general action persistence', () => {
await disconnect?.(); await disconnect?.();
return; return;
} }
await db.inputEvent.deleteMany({ where: { requestId } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } }); await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
await db.logEntry.deleteMany({ await db.logEntry.deleteMany({
where: { where: {
@@ -277,7 +295,7 @@ integration('immediate general action persistence', () => {
await disconnect?.(); 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 = { const snapshot: TurnWorldSnapshot = {
generals: [general], generals: [general],
cities: [ cities: [
@@ -376,10 +394,15 @@ integration('immediate general action persistence', () => {
}); });
const stateStore = { const stateStore = {
loadLastTurnTime: async () => new Date(state.lastTurnTime), loadLastTurnTime: async () => new Date(state.lastTurnTime),
loadNextGeneralTurnTime: async () => null, loadNextGeneralTurnTime: async () => general.turnTime,
saveLastTurnTime: async () => {}, saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined, loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {}, saveCheckpoint: async () => {},
loadGameClock: async () => ({
mode: 'realtime' as const,
phase: 'PREOPEN' as const,
now: world.getGameNow(new Date()),
}),
}; };
const processor = { const processor = {
run: async () => { run: async () => {
@@ -597,5 +620,108 @@ integration('immediate general action persistence', () => {
chiefGeneralId: generalId, chiefGeneralId: generalId,
rice: 2_000, 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, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: options.lastTurnTime ?? new Date('0180-01-01T00:00:00Z'), 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: { meta: {
hiddenSeed: 'immediate-action-test', hiddenSeed: 'immediate-action-test',
killturn: 24, killturn: 24,
@@ -212,6 +212,24 @@ describe('runtime clock shift', () => {
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 }); 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 () => { it('rejects gameplay commits while the durable clock is suspended', async () => {
const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' }); 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); const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => { 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 () => { it('durably rebases a long realtime backlog before executing another turn', async () => {
const wallNow = new Date('2026-08-23T01:35:00.000Z'); const wallNow = new Date('2026-08-23T01:35:00.000Z');
const clock = new ManualClock(wallNow.getTime()); const clock = new ManualClock(wallNow.getTime());
@@ -316,7 +316,13 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
where: where:
candidate.status === 'QUEUED' candidate.status === 'QUEUED'
? { id: candidate.id, status: 'QUEUED' } ? { id: candidate.id, status: 'QUEUED' }
: { id: candidate.id, status: 'RUNNING', leaseUntil: candidate.leaseUntil }, : {
id: candidate.id,
status: 'RUNNING',
// 마이크로초 lease를 Date로 왕복한 값과 equality CAS하면
// 만료된 행도 선점하지 못한다. 갱신 race는 만료 조건으로 막는다.
leaseUntil: candidate.leaseUntil ? { lt: now } : null,
},
data: { data: {
status: 'RUNNING', status: 'RUNNING',
startedAt: candidate.startedAt ?? now, startedAt: candidate.startedAt ?? now,
@@ -802,7 +802,13 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
where: where:
candidate.status === 'QUEUED' candidate.status === 'QUEUED'
? { id: candidate.id, status: 'QUEUED' } ? { id: candidate.id, status: 'QUEUED' }
: { id: candidate.id, status: 'RUNNING', leaseUntil: candidate.leaseUntil }, : {
id: candidate.id,
status: 'RUNNING',
// DB microseconds는 JS Date로 읽을 때 잘린다. timestamp
// 동등 비교 대신 갱신되지 않은 만료 lease인지 DB에서 재검사한다.
leaseUntil: candidate.leaseUntil ? { lt: now } : null,
},
data: { data: {
status: 'RUNNING', status: 'RUNNING',
startedAt: candidate.startedAt ?? now, startedAt: candidate.startedAt ?? now,
@@ -108,7 +108,8 @@ describeDatabase('gateway release operation persistence', () => {
await connector.prisma.$executeRaw` await connector.prisma.$executeRaw`
UPDATE "gateway_release_operation" UPDATE "gateway_release_operation"
SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second' SET "lease_until" = date_trunc('milliseconds', CURRENT_TIMESTAMP)
- INTERVAL '1 second' + INTERVAL '123 microseconds'
WHERE "id" = ${operation.id} WHERE "id" = ${operation.id}
`; `;
await expect( await expect(
@@ -417,7 +417,8 @@ describeDatabase('gateway operation lease and profile serialization', () => {
).resolves.toBeNull(); ).resolves.toBeNull();
await connector.prisma.$executeRaw` await connector.prisma.$executeRaw`
UPDATE "gateway_operation" UPDATE "gateway_operation"
SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second' SET "lease_until" = date_trunc('milliseconds', CURRENT_TIMESTAMP)
- INTERVAL '1 second' + INTERVAL '123 microseconds'
WHERE "id" = ${operation.id} WHERE "id" = ${operation.id}
`; `;
const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), { const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), {
+28
View File
@@ -20,6 +20,21 @@ Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
## 실행 모드 ## 실행 모드
`PREOPEN`은 사용자 명령을 처리하는 가오픈 상태입니다. 장수 생성·선택·빙의,
사전 거병·삭제·예약과 설정은 durable ENGINE 명령으로 처리하지만, 자동 턴과
명시적 `run` 요청의 시간 진행은 `RUNNING`/`MANUAL`에서만 실행합니다.
`SUSPENDED`/`RECONCILING`/`COMPLETED`를 가오픈과 같은 상태로 취급하지 않습니다.
가오픈 삭제 대기는 생성 접수의 DB WALL_TIME에 설정한 턴 간격 배수를 더한
`prestart_delete_after`로 판정합니다. 음수 GAME tick이나 화면의 게임 날짜를
그 현실 기한과 비교하지 않습니다. 사전 거병·삭제의 개시 판정은 PREOPEN phase와
`last_turn_tick`을 사용하며, Ref의 개시 cursor 동등 경계는 유지합니다.
`opentime` 표시 문자열은 일정 재투영 뒤에도 이 판정의 권위가 아닙니다.
일반·선택 장수 생성의 GAME 효과와 RNG에는 daemon의 `processing_game_tick`
사용하고, 최초 실행 턴은 PREOPEN에서 tick 0보다 앞에 놓지 않습니다. 계정에서
가져오는 제재의 Unix `expire`는 접수 DB WALL_TIME과 비교합니다.
- `GAME_CLOCK_MODE=realtime`: `clock_wall_anchor` 이후의 실제 경과시간을 - `GAME_CLOCK_MODE=realtime`: `clock_wall_anchor` 이후의 실제 경과시간을
game tick으로 환산합니다. 벽시계가 뒤로 보정되어도 game tick은 감소하지 game tick으로 환산합니다. 벽시계가 뒤로 보정되어도 game tick은 감소하지
않습니다. 않습니다.
@@ -66,6 +81,9 @@ realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용
장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행 장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행
시각으로 다시 고정합니다. 시각으로 다시 고정합니다.
PREOPEN에서 이 투영 조정을 수행할 때는 예정된 `clock_wall_anchor`를 유지합니다.
GAME 표시 좌표를 옮기는 명령이 별도로 예약된 정식 WALL 오픈을 앞당기지 않습니다.
통일 후 이민족 선택을 기다리는 `UNIFICATION_WAIT`는 일반 maintenance 재개와 통일 후 이민족 선택을 기다리는 `UNIFICATION_WAIT`는 일반 maintenance 재개와
다릅니다. 운영상 STOP된 profile을 RESUME하면 Gateway는 프로세스만 다시 띄우고 다릅니다. 운영상 STOP된 profile을 RESUME하면 Gateway는 프로세스만 다시 띄우고
게임 clock은 `SUSPENDED`로 유지합니다. daemon이 수신자 소유권과 command fence를 게임 clock은 `SUSPENDED`로 유지합니다. daemon이 수신자 소유권과 command fence를
@@ -89,6 +107,10 @@ game tick을 미리 고정하지 않으며, daemon이 clock lock/fence 아래서
적용하지 않습니다. worker history retention·lease·retry는 DB WALL_TIME, 적용하지 않습니다. worker history retention·lease·retry는 DB WALL_TIME,
프로세스 대기 budget은 monotonic time입니다. 프로세스 대기 budget은 monotonic time입니다.
daemon 종료가 이미 local queue에 있으면 새 DB 명령을 선점하지 않습니다.
실행하지 않을 명령을 `PROCESSING`으로 남겨 교체 daemon이 lease 만료를 기다리게
하지 않고, 기존 PENDING 명령은 즉시 다음 daemon이 claim할 수 있게 유지합니다.
경매 입찰은 `requested_at_wall`로 현실 요청을, `occurred_game_tick`으로 GAME 경매 입찰은 `requested_at_wall`로 현실 요청을, `occurred_game_tick`으로 GAME
사건을 별도 기록합니다. bid 표시 투영은 같은 game time을 보존하고, 사건을 별도 기록합니다. bid 표시 투영은 같은 game time을 보존하고,
optimistic 경합 판정은 임의 UUID의 optimistic 경합 판정은 임의 UUID의
@@ -97,3 +119,9 @@ optimistic 경합 판정은 임의 UUID의
`FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시 `FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시
재시도합니다. Redis history의 score는 보존 기간 계산을 위해 운영 벽시계를 재시도합니다. Redis history의 score는 보존 기간 계산을 위해 운영 벽시계를
사용합니다. 사용합니다.
## 통합 검증
`CLOCK_RECONCILIATION_DATABASE_URL``REDIS_URL`을 사용하는 실제 reconciliation
검증은 conditional integration registry의 core group에 포함합니다. 일반 단위
테스트의 조건부 skip을 실제 PostgreSQL/Redis 검증 통과로 간주하지 않습니다.
+20
View File
@@ -20,6 +20,17 @@ explicitly calls them game projections.
## Game database inventory ## Game database inventory
PREOPEN user commands are executable even though scheduled turns are stopped.
Their authoritative processing coordinate remains signed GAME_TIME. Direct and
selection-pool creation clamp only the first runnable schedule to opening tick
zero; receipt audit, account sanctions, and pre-opening deletion use WALL_TIME.
| Pre-opening/account rule | Domain and authority | Reconciliation |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| `general.meta.prestart_delete_after` | WALL deadline from the creation `InputEvent.createdAt` plus the configured minimum-turn wall duration; legacy missing cutoffs use `general_access_log.last_refresh` once | excluded; never shift with GAME projections |
| account `legacyPenalty.*.*.expire` copied at general creation/possession | WALL Unix seconds compared with the durable request receipt | excluded; an expired account sanction must not reappear when GAME dates are in the past |
| pre-opening action availability | `clock_phase`, then executed `last_turn_tick` (zero equality retained); uninitialized legacy readers may compare old date fields | phase/cursor authority, not `meta.opentime` display text |
`Pause` means whether the rule continues to age during `SUSPENDED` or `Pause` means whether the rule continues to age during `SUSPENDED` or
`RECONCILING`. `Projection` means a non-authoritative compatibility/display `RECONCILING`. `Projection` means a non-authoritative compatibility/display
representation. representation.
@@ -173,3 +184,12 @@ Migration `20260903201500_complete_invader_game_clock` moves historical
unchosen invader actions at `max(created_game_tick, world_state.clock_tick)`. unchosen invader actions at `max(created_game_tick, world_state.clock_tick)`.
Neither migration shifts a WALL occurrence or turns one rule into a clock Neither migration shifts a WALL occurrence or turns one rule into a clock
fallback. fallback.
## WALL lease precision and query parameters
Gateway operation/release lease recovery rechecks expiration in the database
under the existing claim lock. It does not compare a JavaScript Date to the
original `timestamptz(6)` value: that round trip loses microseconds and can prevent
expired jobs from being reclaimed. Owner fences and renewal checks remain active.
Read-model outbox dispatch casts an injected WALL timestamp explicitly before
subtracting a retry interval; production still defaults to the database WALL clock.
@@ -33,8 +33,7 @@ export interface ReadModelOutboxDispatchResult {
failed: number; failed: number;
} }
const normalizeLimit = (value: number | undefined): number => const normalizeLimit = (value: number | undefined): number => Math.min(500, Math.max(1, Math.floor(value ?? 50)));
Math.min(500, Math.max(1, Math.floor(value ?? 50)));
const normalizeDuration = (value: number | undefined, fallback: number): number => const normalizeDuration = (value: number | undefined, fallback: number): number =>
Math.max(1, Math.floor(value ?? fallback)); Math.max(1, Math.floor(value ?? fallback));
@@ -59,7 +58,9 @@ export const claimReadModelOutboxBatch = async (
const limit = normalizeLimit(options.limit); const limit = normalizeLimit(options.limit);
const leaseMs = normalizeDuration(options.leaseMs, 30_000); const leaseMs = normalizeDuration(options.leaseMs, 30_000);
const nowSql = options.now const nowSql = options.now
? GamePrisma.sql`${options.now}` ? // Date bind는 interval 뺄셈에서 interval로 추론될 수 있으므로
// 저장 column과 같은 UTC wall timestamp 타입을 명시한다.
GamePrisma.sql`${options.now}::timestamp`
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`; : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
const rows = await db.$queryRaw<ClaimedRow[]>(GamePrisma.sql` const rows = await db.$queryRaw<ClaimedRow[]>(GamePrisma.sql`
WITH candidates AS ( WITH candidates AS (
@@ -170,7 +171,11 @@ export const dispatchReadModelOutboxBatch = async (
owner: options.owner, owner: options.owner,
error, error,
...(testNow ...(testNow
? { availableAt: new Date(testNow().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)) } ? {
availableAt: new Date(
testNow().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)
),
}
: { availableAfterMs: retryDelayMs(item.attempts, retryBaseMs, retryMaxMs) }), : { availableAfterMs: retryDelayMs(item.attempts, retryBaseMs, retryMaxMs) }),
}); });
} }
@@ -1,4 +1,5 @@
# Environment variable Execution mode # Environment variable Execution mode
CLOCK_RECONCILIATION_DATABASE_URL core
CREATE_GENERAL_DATABASE_URL create_general CREATE_GENERAL_DATABASE_URL create_general
CURRENT_SEASON_FIXTURE_DATABASE_URL external_fixture CURRENT_SEASON_FIXTURE_DATABASE_URL external_fixture
GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime
1 # Environment variable Execution mode
2 CLOCK_RECONCILIATION_DATABASE_URL core
3 CREATE_GENERAL_DATABASE_URL create_general
4 CURRENT_SEASON_FIXTURE_DATABASE_URL external_fixture
5 GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime
+1
View File
@@ -596,6 +596,7 @@ database_url=$(build_database_url "$integration_schema")
export POSTGRES_SCHEMA=$integration_schema export POSTGRES_SCHEMA=$integration_schema
export DATABASE_URL=$database_url export DATABASE_URL=$database_url
export INPUT_EVENT_DATABASE_URL=$database_url export INPUT_EVENT_DATABASE_URL=$database_url
export CLOCK_RECONCILIATION_DATABASE_URL=$database_url
export GENERAL_LIFECYCLE_DATABASE_URL=$database_url export GENERAL_LIFECYCLE_DATABASE_URL=$database_url
export TURN_DAEMON_LEASE_DATABASE_URL=$database_url export TURN_DAEMON_LEASE_DATABASE_URL=$database_url
export TURN_DIFFERENTIAL_DATABASE_URL=$database_url export TURN_DIFFERENTIAL_DATABASE_URL=$database_url