diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index 56090bda..3ee87f94 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -67,6 +67,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam any: { ban: { expire: 4_102_444_800, value: 1 }, expired: { expire: 1, value: 9 }, + expiredWall: { expire: Date.parse('2026-08-01T00:00:00.000Z') / 1000, value: 9 }, }, hwe: { ban: { expire: 4_102_444_800, value: 2 }, @@ -149,8 +150,9 @@ integration('generic general creation through the durable turn daemon', () => { await seedScenarioToDatabase({ scenarioId: 2, databaseUrl: databaseUrl!, - now: new Date('2099-07-30T12:00:00.000Z'), + now: new Date('2026-07-30T12:00:00.000Z'), installOptions: { + openAt: new Date(Date.now() + 86_400_000), turnTermMinutes: 5, npcMode: 0, showImgLevel: 3, @@ -289,6 +291,7 @@ integration('generic general creation through the durable turn daemon', () => { }, }); expect(created.affinity).toBeGreaterThanOrEqual(1); + expect(created.penalty).toEqual({ ban: 2, chat: 3 }); expect(created.affinity).toBeLessThanOrEqual(150); expect(created.meta).toMatchObject({ 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'); } 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( new Date((created.meta as Record).prestart_delete_after as string).getTime() - acceptedEvent.createdAt.getTime() diff --git a/app/game-api/test/npcPossession.integration.test.ts b/app/game-api/test/npcPossession.integration.test.ts index 2d740919..bbb807ba 100644 --- a/app/game-api/test/npcPossession.integration.test.ts +++ b/app/game-api/test/npcPossession.integration.test.ts @@ -379,7 +379,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae attempts: 1, 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({ where: { OR: [ @@ -671,7 +671,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae await db.npcSelectionToken.update({ where: { ownerUserId: rejectedUserId }, - data: { validUntil: new Date('2000-01-01T00:00:00.000Z') }, + data: { validUntilTick: -1n }, }); await expect( appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({ diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 192d5b7c..a99d45b0 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -142,8 +142,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => { await seedScenarioToDatabase({ scenarioId: 903, databaseUrl: databaseUrl!, - now: new Date('2099-07-30T12:00:00.000Z'), + now: new Date('2026-07-30T12:00:00.000Z'), installOptions: { + openAt: new Date(Date.now() + 86_400_000), turnTermMinutes: 5, npcMode: 2, showImgLevel: 3, @@ -274,6 +275,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => { status: 'SUCCEEDED', 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) { throw new Error('selected general must have an initial access timestamp'); } diff --git a/app/game-engine/src/lifecycle/databaseCommandQueue.ts b/app/game-engine/src/lifecycle/databaseCommandQueue.ts index c4936c12..c1e216ac 100644 --- a/app/game-engine/src/lifecycle/databaseCommandQueue.ts +++ b/app/game-engine/src/lifecycle/databaseCommandQueue.ts @@ -33,6 +33,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T async drain(): Promise { 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(); return local.concat(remote); } @@ -116,7 +121,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T orderBy: { id: 'asc' }, 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 currentRevision = world?.clockRevision ?? null; const maintenanceSuspended = diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index bc8f2a18..da8e435a 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -158,6 +158,20 @@ export class TurnDaemonLifecycle { 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) { await this.runOnce(this.pendingRun); this.pendingRun = null; @@ -169,23 +183,6 @@ export class TurnDaemonLifecycle { await this.clock.sleepMs(200); 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') { // Ref observes all generals due before one monthly boundary in // a single snapshot. Manual mode advances directly to that diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 1ac2ea93..37e8edad 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1711,7 +1711,11 @@ export class InMemoryTurnWorld { // Rebasing is also the explicit resume checkpoint. Realtime mode // must not replay the operational downtime after an administrator // 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, meta: nextMeta, }; diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index f01fa873..491e3a60 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -96,7 +96,7 @@ export const normalizeJoinSpecialityCode = (value: unknown): string | null => export const resolveLegacyPenalty = ( rawPenalty: Record | undefined, profileId: string, - acceptedAt: Date + requestedAtWall: Date ): Record => { if (!rawPenalty) { return {}; @@ -105,7 +105,7 @@ export const resolveLegacyPenalty = ( ...asRecord(rawPenalty.any), ...asRecord(rawPenalty[profileId]), }; - const acceptedAtSeconds = acceptedAt.getTime() / 1000; + const acceptedAtSeconds = requestedAtWall.getTime() / 1000; const result: Record = {}; for (const [key, rawEntry] of Object.entries(merged)) { const entry = asRecord(rawEntry); @@ -654,12 +654,7 @@ export const createGeneralFromJoin = async (options: { const generalId = world.getNextGeneralId(); const rng = new RandUtil( new LiteHashDRBG( - buildJoinCreateGeneralSeed( - hiddenSeed, - input.seedOwnerIdentity, - world.dateToGameTick(acceptedAt), - generalId - ) + buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt), generalId) ) ); const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01); @@ -805,7 +800,9 @@ export const createGeneralFromJoin = async (options: { meta: {}, }, 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: { previous: finalInheritancePoint, }, diff --git a/app/game-engine/src/turn/prestartDeletion.ts b/app/game-engine/src/turn/prestartDeletion.ts index 2e1bf018..8cb0bb1a 100644 --- a/app/game-engine/src/turn/prestartDeletion.ts +++ b/app/game-engine/src/turn/prestartDeletion.ts @@ -1,7 +1,22 @@ import { asNumber, asRecord } from '@sammo-ts/common'; +import type { TurnWorldState } from './types.js'; const DEFAULT_MIN_TURNS = 2; +/** 표시용 opentime과 재투영된 lastTurnTime 대신 오픈 phase와 실행 cursor로 판정한다. */ +export const hasStartedForPrestartActions = ( + state: Pick +): 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): Date | null => { const value = meta.prestart_delete_after; if (typeof value !== 'string' || !value.trim()) { diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 2a7788ca..81014aca 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -70,7 +70,12 @@ import { } from './selectPoolService.js'; import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.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 { executeInheritanceAction } from './inheritanceActionService.js'; import { applyNpcPolicyMutation } from './npcPolicyMutation.js'; @@ -344,8 +349,14 @@ async function handleJoinCreateGeneral( throw new Error('Join world state is missing.'); } const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); - const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt); - const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt); + const processingGameTick = Reflect.get(command, 'processingGameTick'); + 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 { return { type: 'joinCreateGeneral', @@ -463,7 +474,10 @@ async function handleSelectPoolCreate( throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.'); } 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 { return { type: 'selectPoolCreate', @@ -1607,8 +1621,7 @@ async function handleEnsureDieOnPrestartStatus( const db = requireCommandDatabase(ctx); const acceptedAt = await resolveCommandAcceptedAt(db, command); const worldState = ctx.world.getState(); - const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null; - if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) { + if (hasStartedForPrestartActions(worldState) || general.nationId !== 0) { return { type: 'ensureDieOnPrestartStatus', generalId: command.generalId, @@ -1643,8 +1656,7 @@ async function handleDieOnPrestart( } const acceptedAt = await resolveCommandAcceptedAt(db, command); const worldState = world.getState(); - const opentime = worldState.meta.opentime as string | undefined; - if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) { + if (hasStartedForPrestartActions(worldState)) { return { type: 'dieOnPrestart', ok: false, @@ -1709,8 +1721,7 @@ async function handleBuildNationCandidate( await assertImmediateGeneralActionActor(ctx, command, general); const worldState = world.getState(); - const opentime = worldState.meta.opentime as string | undefined; - if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) { + if (hasStartedForPrestartActions(worldState)) { return { type: 'buildNationCandidate', ok: false, diff --git a/app/game-engine/test/clockReconciliation.integration.test.ts b/app/game-engine/test/clockReconciliation.integration.test.ts index f8518338..54125f60 100644 --- a/app/game-engine/test/clockReconciliation.integration.test.ts +++ b/app/game-engine/test/clockReconciliation.integration.test.ts @@ -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({ diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index a5bf9f0c..c9fa9060 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -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 diff --git a/app/game-engine/test/dieOnPrestart.test.ts b/app/game-engine/test/dieOnPrestart.test.ts index 54a5d5d6..16005d12 100644 --- a/app/game-engine/test/dieOnPrestart.test.ts +++ b/app/game-engine/test/dieOnPrestart.test.ts @@ -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( diff --git a/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts b/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts index 11233213..3b145426 100644 --- a/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts +++ b/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts @@ -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); }); }); diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts index 7fa43849..1f81bb55 100644 --- a/app/game-engine/test/myInformationCommands.test.ts +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -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, diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index 097cb22c..886fe6e3 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -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' }); diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index b69f23fb..689e3489 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -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()); diff --git a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts index 310977c3..92e2c93d 100644 --- a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts +++ b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts @@ -316,7 +316,13 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat where: candidate.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: { status: 'RUNNING', startedAt: candidate.startedAt ?? now, diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 36760808..dd268536 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -802,7 +802,13 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat where: candidate.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: { status: 'RUNNING', startedAt: candidate.startedAt ?? now, diff --git a/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts b/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts index d0265124..60b69656 100644 --- a/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts +++ b/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts @@ -108,7 +108,8 @@ describeDatabase('gateway release operation persistence', () => { await connector.prisma.$executeRaw` 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} `; await expect( diff --git a/app/gateway-api/test/profileOperationLease.integration.test.ts b/app/gateway-api/test/profileOperationLease.integration.test.ts index f707a0be..7c51e391 100644 --- a/app/gateway-api/test/profileOperationLease.integration.test.ts +++ b/app/gateway-api/test/profileOperationLease.integration.test.ts @@ -417,7 +417,8 @@ describeDatabase('gateway operation lease and profile serialization', () => { ).resolves.toBeNull(); await connector.prisma.$executeRaw` 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} `; const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), { diff --git a/docs/architecture/game-clock.md b/docs/architecture/game-clock.md index 320d0dba..7450ed2c 100644 --- a/docs/architecture/game-clock.md +++ b/docs/architecture/game-clock.md @@ -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 tick으로 환산합니다. 벽시계가 뒤로 보정되어도 game tick은 감소하지 않습니다. @@ -66,6 +81,9 @@ realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용 장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행 시각으로 다시 고정합니다. +PREOPEN에서 이 투영 조정을 수행할 때는 예정된 `clock_wall_anchor`를 유지합니다. +GAME 표시 좌표를 옮기는 명령이 별도로 예약된 정식 WALL 오픈을 앞당기지 않습니다. + 통일 후 이민족 선택을 기다리는 `UNIFICATION_WAIT`는 일반 maintenance 재개와 다릅니다. 운영상 STOP된 profile을 RESUME하면 Gateway는 프로세스만 다시 띄우고 게임 clock은 `SUSPENDED`로 유지합니다. daemon이 수신자 소유권과 command fence를 @@ -89,6 +107,10 @@ game tick을 미리 고정하지 않으며, daemon이 clock lock/fence 아래서 적용하지 않습니다. worker history retention·lease·retry는 DB WALL_TIME, 프로세스 대기 budget은 monotonic time입니다. +daemon 종료가 이미 local queue에 있으면 새 DB 명령을 선점하지 않습니다. +실행하지 않을 명령을 `PROCESSING`으로 남겨 교체 daemon이 lease 만료를 기다리게 +하지 않고, 기존 PENDING 명령은 즉시 다음 daemon이 claim할 수 있게 유지합니다. + 경매 입찰은 `requested_at_wall`로 현실 요청을, `occurred_game_tick`으로 GAME 사건을 별도 기록합니다. bid 표시 투영은 같은 game time을 보존하고, optimistic 경합 판정은 임의 UUID의 @@ -97,3 +119,9 @@ optimistic 경합 판정은 임의 UUID의 `FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시 재시도합니다. Redis history의 score는 보존 기간 계산을 위해 운영 벽시계를 사용합니다. + +## 통합 검증 + +`CLOCK_RECONCILIATION_DATABASE_URL`과 `REDIS_URL`을 사용하는 실제 reconciliation +검증은 conditional integration registry의 core group에 포함합니다. 일반 단위 +테스트의 조건부 skip을 실제 PostgreSQL/Redis 검증 통과로 간주하지 않습니다. diff --git a/docs/architecture/time-domains.md b/docs/architecture/time-domains.md index 5ffea926..379424ea 100644 --- a/docs/architecture/time-domains.md +++ b/docs/architecture/time-domains.md @@ -20,6 +20,17 @@ explicitly calls them game projections. ## 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 `RECONCILING`. `Projection` means a non-authoritative compatibility/display 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)`. Neither migration shifts a WALL occurrence or turns one rule into a clock 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. diff --git a/packages/infra/src/readModelOutboxDispatcher.ts b/packages/infra/src/readModelOutboxDispatcher.ts index d0d40c6b..285dd0e9 100644 --- a/packages/infra/src/readModelOutboxDispatcher.ts +++ b/packages/infra/src/readModelOutboxDispatcher.ts @@ -33,8 +33,7 @@ export interface ReadModelOutboxDispatchResult { failed: number; } -const normalizeLimit = (value: number | undefined): number => - Math.min(500, Math.max(1, Math.floor(value ?? 50))); +const normalizeLimit = (value: number | undefined): number => Math.min(500, Math.max(1, Math.floor(value ?? 50))); const normalizeDuration = (value: number | undefined, fallback: number): number => Math.max(1, Math.floor(value ?? fallback)); @@ -59,7 +58,9 @@ export const claimReadModelOutboxBatch = async ( const limit = normalizeLimit(options.limit); const leaseMs = normalizeDuration(options.leaseMs, 30_000); 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')`; const rows = await db.$queryRaw(GamePrisma.sql` WITH candidates AS ( @@ -170,7 +171,11 @@ export const dispatchReadModelOutboxBatch = async ( owner: options.owner, error, ...(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) }), }); } diff --git a/tools/conditional-integration-registry.tsv b/tools/conditional-integration-registry.tsv index 41d1bc1f..74fe9f77 100644 --- a/tools/conditional-integration-registry.tsv +++ b/tools/conditional-integration-registry.tsv @@ -1,4 +1,5 @@ # Environment variable Execution mode +CLOCK_RECONCILIATION_DATABASE_URL core CREATE_GENERAL_DATABASE_URL create_general CURRENT_SEASON_FIXTURE_DATABASE_URL external_fixture GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index b6bb0a45..804aa084 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -596,6 +596,7 @@ database_url=$(build_database_url "$integration_schema") export POSTGRES_SCHEMA=$integration_schema export 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 TURN_DAEMON_LEASE_DATABASE_URL=$database_url export TURN_DIFFERENTIAL_DATABASE_URL=$database_url