From abceee831514681cfd36e4ea46cf2ae4512f35e0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 3 Sep 2026 16:01:19 +0000 Subject: [PATCH] =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EA=B3=BC=20=EC=A0=95=EC=A7=80=20=EC=A4=91=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=C2=B7=EB=B2=A0=ED=8C=85=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/auction/open.ts | 5 +- app/game-api/src/auction/scheduler.ts | 15 +- app/game-api/src/auction/worker.ts | 89 +++---- app/game-api/src/daemon/databaseTransport.ts | 55 ++-- app/game-api/src/inputEventBoundary.ts | 135 +++++----- app/game-api/src/messages/store.ts | 228 ++++++++++------- app/game-api/src/router/auction/index.ts | 14 +- app/game-api/src/router/betting/index.ts | 45 +++- app/game-api/src/router/diplomacy/index.ts | 13 +- app/game-api/src/router/join/index.ts | 74 +++--- app/game-api/src/router/messages/index.ts | 32 ++- app/game-api/src/router/tournament/index.ts | 49 +++- app/game-api/src/router/vote/index.ts | 36 ++- app/game-api/src/services/redisClockFence.ts | 37 ++- app/game-api/src/services/turnEngineStatus.ts | 31 ++- app/game-api/src/services/wallClock.ts | 13 + .../src/services/webPushOutboxWorker.ts | 64 +++-- app/game-api/src/tournament/store.ts | 7 +- app/game-api/src/tournament/workerHelpers.ts | 5 +- app/game-api/src/trpc.ts | 123 +++++---- app/game-api/test/auctionRouter.test.ts | 19 +- .../test/auctionWorker.integration.test.ts | 35 ++- app/game-api/test/auctionWorker.test.ts | 44 ++-- app/game-api/test/diplomacyRouter.test.ts | 11 +- .../test/generalAccessTracking.test.ts | 10 +- app/game-api/test/idempotentTransport.test.ts | 18 +- .../inputEventBoundary.integration.test.ts | 11 +- app/game-api/test/inputEventJournal.test.ts | 43 +++- app/game-api/test/lobbyRouter.test.ts | 1 + .../test/messageTombstone.integration.test.ts | 42 +++- app/game-api/test/messagesRouter.test.ts | 228 ++++++++++++----- .../nationBettingRouter.integration.test.ts | 110 +++++++- .../test/npcPossession.integration.test.ts | 17 +- .../test/readModelOutboxWorker.test.ts | 34 +-- app/game-api/test/router.test.ts | 4 +- .../test/selectPool.integration.test.ts | 54 ++-- app/game-api/test/tournamentRouter.test.ts | 84 ++++++- app/game-api/test/turnEngineStatus.test.ts | 20 +- app/game-api/test/voteRouter.test.ts | 33 +-- app/game-engine/src/auction/bidder.ts | 73 +++--- app/game-engine/src/auction/finalizer.ts | 31 ++- app/game-engine/src/auction/opener.ts | 24 +- .../src/lifecycle/databaseCommandQueue.ts | 198 ++++++++------- .../src/lifecycle/inMemoryControlQueue.ts | 10 +- .../src/lifecycle/redisCommandStream.ts | 8 +- .../src/lifecycle/turnDaemonLifecycle.ts | 4 +- app/game-engine/src/lifecycle/types.ts | 2 +- .../src/turn/actionableMessageResponse.ts | 87 ++++--- .../src/turn/clockReconciliation.ts | 158 ++++++++++-- app/game-engine/src/turn/commandRegistry.ts | 5 +- app/game-engine/src/turn/databaseHooks.ts | 44 +--- .../src/turn/gatewayAdminActions.ts | 41 +-- .../src/turn/gatewayProfileGate.ts | 4 +- .../src/turn/inMemoryTurnProcessor.ts | 10 +- .../src/turn/inheritanceActionService.ts | 58 ++++- .../src/turn/npcPossessionService.ts | 108 +++++--- app/game-engine/src/turn/reservedTurnStore.ts | 39 ++- app/game-engine/src/turn/runtimeClockShift.ts | 5 +- .../src/turn/runtimeGameSettings.ts | 5 +- app/game-engine/src/turn/selectPoolService.ts | 114 ++++----- .../src/turn/unificationPersistence.ts | 36 +-- .../src/turn/worldCommandHandler.ts | 72 ++---- .../test/actionableMessageResponse.test.ts | 73 +++++- .../test/auctionBidCompatibility.test.ts | 31 +-- .../auctionFinalizerCompatibility.test.ts | 51 +++- .../clockReconciliation.integration.test.ts | 58 ++++- .../databaseCommandQueue.integration.test.ts | 235 +++++++++++++++++- ...tanceActionPersistence.integration.test.ts | 41 ++- .../test/inputEventAtomicity.test.ts | 1 + app/game-engine/test/scenarioSeeder.test.ts | 2 + .../test/selectPoolReservation.test.ts | 8 +- app/game-engine/test/voteReward.test.ts | 89 +++---- .../src/utils/gameServerActivity.ts | 6 +- app/gateway-api/src/adminRouter.ts | 12 +- .../src/orchestrator/gatewayOrchestrator.ts | 4 +- .../orchestrator/gatewayReleaseRepository.ts | 30 ++- .../src/orchestrator/profileRepository.ts | 30 ++- .../src/scenario/scenarioCatalog.ts | 8 +- app/gateway-api/src/webPush/coordinator.ts | 109 ++++---- ...tewayReleaseRepository.integration.test.ts | 14 +- .../profileOperationLease.integration.test.ts | 17 +- app/gateway-api/test/releaseManifest.test.ts | 2 +- .../composables/useAdminProfileNavigation.ts | 4 +- app/release-controller/src/index.ts | 2 +- .../src/releaseController.ts | 4 +- app/release-controller/src/selfUpgrade.ts | 4 +- .../architecture/game-clock-participants.json | 115 ++++++++- .../architecture/game-clock-reconciliation.md | 43 +++- docs/architecture/game-clock.md | 54 ++-- docs/architecture/time-domains.md | 166 +++++++++++++ packages/common/src/time/GameClock.ts | 30 ++- .../common/src/tournament/sourceRevision.ts | 2 +- packages/common/src/turnDaemon/types.ts | 13 +- packages/common/test/gameClock.test.ts | 18 ++ packages/infra/package.json | 1 + packages/infra/prisma/game.prisma | 90 +++++-- .../migration.sql | 160 ++++++++++++ packages/infra/prisma/migrations/README.md | 10 + .../scripts/verify-time-domain-migration.sh | 166 +++++++++++++ packages/infra/src/db.ts | 2 + packages/infra/src/index.ts | 1 + packages/infra/src/messageEnvelope.ts | 98 ++++++++ .../infra/src/readModelOutboxDispatcher.ts | 82 +++--- .../test/readModelOutboxDispatcher.test.ts | 26 +- release-manifest.json | 2 +- tools/check-game-clock-participants.mjs | 12 +- .../test/auctionFlow.test.ts | 25 +- ...sionSelectionReference.integration.test.ts | 2 +- 108 files changed, 3504 insertions(+), 1473 deletions(-) create mode 100644 app/game-api/src/services/wallClock.ts create mode 100644 docs/architecture/time-domains.md create mode 100644 packages/infra/prisma/migrations/20260903140000_split_message_wall_and_game_time/migration.sql create mode 100644 packages/infra/scripts/verify-time-domain-migration.sh create mode 100644 packages/infra/src/messageEnvelope.ts diff --git a/app/game-api/src/auction/open.ts b/app/game-api/src/auction/open.ts index 7be92d80..2b1d6f57 100644 --- a/app/game-api/src/auction/open.ts +++ b/app/game-api/src/auction/open.ts @@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async ( generalId: number, input: OpenAuctionInput, requestId?: string -): Promise<{ auctionId: number; closeAt: string }> => { +): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => { const result = await ctx.turnDaemon.requestCommand({ type: 'auctionOpen', ...(requestId ? { requestId } : {}), @@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async ( const closeAt = new Date(result.closeAt); const gameTime = await loadCurrentGameTime(ctx.db); await ctx.redis.zAdd(timerKeys.timerKey, [ - { score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) }, + { score: resolveAuctionTimerScore(gameTime, closeAt, BigInt(result.closeTick)), value: String(result.auctionId) }, ]); return { auctionId: result.auctionId, closeAt: result.closeAt, + closeTick: result.closeTick, }; }; diff --git a/app/game-api/src/auction/scheduler.ts b/app/game-api/src/auction/scheduler.ts index d0354491..8cfa82b8 100644 --- a/app/game-api/src/auction/scheduler.ts +++ b/app/game-api/src/auction/scheduler.ts @@ -10,18 +10,19 @@ interface RedisSortedSetClient { } export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => { - if (closeTick !== null && closeTick !== undefined) { - const value = Number(closeTick); - if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`); - return value; - } - return time.dateToTick(closeAt) ?? closeAt.getTime(); + void time; + void closeAt; + if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.'); + const value = Number(closeTick); + if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`); + return value; }; export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => { if (row.status === 'FINALIZING') { // 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다. - return time.tick ?? time.now.getTime(); + if (time.tick === null) throw new Error('Current game tick is required for auction recovery.'); + return time.tick; } return resolveAuctionTimerScore(time, row.closeAt, row.closeTick); }; diff --git a/app/game-api/src/auction/worker.ts b/app/game-api/src/auction/worker.ts index 2a660348..03589514 100644 --- a/app/game-api/src/auction/worker.ts +++ b/app/game-api/src/auction/worker.ts @@ -48,7 +48,7 @@ const AUCTION_FINALIZE_RECOVERY_LIMIT = 1; interface AuctionFinalizeDeadline { closeAt: Date; - closeTick: bigint | null; + closeTick: bigint; } interface AuctionFinalizeCommand { @@ -56,19 +56,10 @@ interface AuctionFinalizeCommand { requestId: string; auctionId: number; expectedCloseAt: string; - expectedCloseTick?: number; + expectedCloseTick: number; } -interface AuctionFinalizeEventRecord { - target: string; - eventType: string; - payload: unknown; - status: string; - result: unknown; -} - -const readSafeCloseTick = (closeTick: bigint | null): number | undefined => { - if (closeTick === null) return undefined; +const readSafeCloseTick = (closeTick: bigint): number => { const value = Number(closeTick); if (!Number.isSafeInteger(value)) { throw new Error(`Auction close tick is unsafe: ${closeTick}`); @@ -81,14 +72,7 @@ export const buildAuctionFinalizeRequestId = ( deadline: AuctionFinalizeDeadline, retry = 0 ): string => { - const generation = - deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`; - const base = `auction:finalize:${auctionId}:${generation}`; - return retry > 0 ? `${base}:retry:${retry}` : base; -}; - -const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => { - const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`; + const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`; return retry > 0 ? `${base}:retry:${retry}` : base; }; @@ -101,7 +85,7 @@ const buildAuctionFinalizeCommand = ( requestId, auctionId, expectedCloseAt: deadline.closeAt.toISOString(), - ...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }), + expectedCloseTick: readSafeCloseTick(deadline.closeTick), }); const isMatchingAuctionFinalizeEvent = ( @@ -113,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = ( payload !== null && typeof payload === 'object' && !Array.isArray(payload) ? (payload as Record) : null; - const expectedGenerationMatches = - payloadRecord?.expectedCloseTick !== undefined - ? payloadRecord.expectedCloseTick === command.expectedCloseTick - : payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt; + const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick; return ( event.target === 'ENGINE' && event.eventType === command.type && @@ -192,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: { if (row.status !== 'OPEN' && row.status !== 'FINALIZING') { continue; } + if (row.closeTick === null) throw new Error(`Auction ${row.id} has no GAME_TIME close authority.`); const deadline = { closeAt: row.closeAt, closeTick: row.closeTick }; const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline); - const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt); - const bases = [...new Set([canonicalBase, legacyBase])]; const events = await options.db.inputEvent.findMany({ where: { - OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]), + OR: [{ requestId: canonicalBase }, { requestId: { startsWith: `${canonicalBase}:retry:` } }], }, select: { requestId: true, target: true, eventType: true, payload: true, status: true }, orderBy: { sequence: 'desc' }, @@ -217,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: { timers.push({ score: row.status === 'FINALIZING' - ? (options.gameTime.tick ?? options.gameTime.now.getTime()) + ? (() => { + if (options.gameTime.tick === null) throw new Error('Current game tick is required.'); + return options.gameTime.tick; + })() : resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick), value: String(row.id), }); @@ -281,10 +264,10 @@ export const processDueAuctionId = async (options: { return { status: 'IGNORED' as const }; } if (current.status === 'OPEN') { - const isDue = - current.closeTick !== null && nowTick !== null - ? current.closeTick <= BigInt(nowTick) - : current.closeTick === null && current.closeAt.getTime() <= now.getTime(); + if (current.closeTick === null || nowTick === null) { + throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`); + } + const isDue = current.closeTick <= BigInt(nowTick); if (!isDue) { return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick }; } @@ -293,24 +276,15 @@ export const processDueAuctionId = async (options: { return { status: 'IGNORED' as const }; } + if (current.closeTick === null) throw new Error(`Auction ${auctionId} has no GAME_TIME close authority.`); const deadline = { closeAt: current.closeAt, closeTick: current.closeTick }; for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) { const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry); - const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry); - const candidateRequestIds = [...new Set([requestId, legacyRequestId])]; - let existing: AuctionFinalizeEventRecord | null = null; - let existingRequestId = requestId; - for (const candidateRequestId of candidateRequestIds) { - existing = await transaction.inputEvent.findUnique({ - where: { requestId: candidateRequestId }, - select: { target: true, eventType: true, payload: true, status: true, result: true }, - }); - if (existing) { - existingRequestId = candidateRequestId; - break; - } - } - const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId); + const existing = await transaction.inputEvent.findUnique({ + where: { requestId }, + select: { target: true, eventType: true, payload: true, status: true, result: true }, + }); + const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId); if (!existing) { const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId); await transaction.inputEvent.create({ @@ -319,26 +293,19 @@ export const processDueAuctionId = async (options: { target: 'ENGINE', eventType: nextCommand.type, payload: { ...nextCommand }, - ...(nowTick === null ? {} : { acceptedGameTick: BigInt(nowTick) }), - ...(options.expectedClockRevision === undefined - ? {} - : { acceptedClockRevision: BigInt(options.expectedClockRevision) }), - ...(options.expectedDeadlineGeneration === undefined - ? {} - : { acceptedDeadlineGeneration: BigInt(options.expectedDeadlineGeneration) }), }, }); return { status: 'PENDING' as const }; } if (!isMatchingAuctionFinalizeEvent(existing, command)) { - throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`); + throw new Error(`Conflicting durable auction finalization event: ${requestId}`); } if (existing.status === 'PENDING' || existing.status === 'PROCESSING') { return { status: 'PENDING' as const }; } if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) { throw new Error( - `Auction remained ${current.status} after successful durable event: ${existingRequestId}` + `Auction remained ${current.status} after successful durable event: ${requestId}` ); } } @@ -386,12 +353,13 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom { name: 'auction-worker-postgres', run: () => postgres.disconnect() }, ]); - let nextResyncAt = Date.now(); + let nextResyncAt = performance.now(); const pendingFinalizationIds = new Set(); try { while (!control.signal.aborted) { const operationalNowMs = Date.now(); + const operationalElapsedMs = performance.now(); const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs)); const gameNowMs = gameTime.now.getTime(); const dueScore = gameTime.tick ?? gameNowMs; @@ -406,9 +374,9 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom await waitForWorkerPoll(control.signal, config.auctionTimerPollMs); continue; } - if (operationalNowMs >= nextResyncAt) { + if (operationalElapsedMs >= nextResyncAt) { await seedAuctionTimers(postgres.prisma, redis.client, keys); - nextResyncAt = operationalNowMs + config.auctionTimerResyncMs; + nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs; } if (pendingFinalizationIds.size > 0) { const reconciliation = await reconcilePendingAuctionTimers({ @@ -483,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom await closeResources(); } }; +import { performance } from 'node:perf_hooks'; diff --git a/app/game-api/src/daemon/databaseTransport.ts b/app/game-api/src/daemon/databaseTransport.ts index a7d42461..96fb60d8 100644 --- a/app/game-api/src/daemon/databaseTransport.ts +++ b/app/game-api/src/daemon/databaseTransport.ts @@ -1,16 +1,15 @@ import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import { acquireGameSchemaAdvisoryXactLock, readInputEventClockCoordinate, type DatabaseClient, type GamePrisma, - type InputEventClockCoordinate, } from '@sammo-ts/infra'; import type { TurnDaemonTransport } from './transport.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js'; -import { loadCurrentGameTime } from '../services/gameClock.js'; const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; @@ -88,9 +87,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { async sendCommand(command: TurnDaemonCommand): Promise { const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID(); const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand; - if (durableCommand.type === 'npcPossessGeneral') { - delete durableCommand.acceptedGameAt; - } + // Rolling-upgrade compatibility: older API versions supplied game + // coordinates. They are deliberately not persisted as command facts; + // the daemon assigns the authoritative processing coordinate while + // claiming the input event under the clock fence. + delete (durableCommand as unknown as Record).acceptedGameAt; + delete (durableCommand as unknown as Record).acceptedGameTick; if (command.type === 'npcPossessGeneral') { const existing = await this.db.inputEvent.findUnique({ where: { requestId }, @@ -112,12 +114,11 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { const coordinate = await readInputEventClockCoordinate(transaction); await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global'); await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`); - const acceptedGameAt = coordinate.gameAt; const token = await transaction.npcSelectionToken.findFirst({ where: { ownerUserId: command.userId, nonce: command.tokenNonce, - validUntil: { gte: acceptedGameAt }, + validUntilTick: { gte: coordinate.gameTick }, }, select: { pickResult: true }, }); @@ -132,11 +133,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { ) { return '선택한 장수가 목록에 없습니다.'; } - const acceptedCommand: Extract = { - ...(durableCommand as Extract), - acceptedGameAt: acceptedGameAt.toISOString(), - }; - await this.createInputEvent(transaction, acceptedCommand, requestId, coordinate); + await this.createInputEvent(transaction, durableCommand, requestId); return null; }); if (rejectionReason) { @@ -145,8 +142,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { } else { if (this.db.$transaction) { await this.db.$transaction(async (transaction) => { - const coordinate = await readInputEventClockCoordinate(transaction); - await this.createInputEvent(transaction, durableCommand, requestId, coordinate); + await this.createInputEvent(transaction, durableCommand, requestId); }); } else { await this.createInputEvent(this.db, durableCommand, requestId); @@ -178,21 +174,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { private async createInputEvent( db: DatabaseClient, command: TurnDaemonCommand, - requestId: string, - coordinate?: InputEventClockCoordinate + requestId: string ): Promise { - const gameTime = coordinate ? null : await loadCurrentGameTime(db, new Date()); - const commandAcceptedTick = Reflect.get(command, 'acceptedGameTick'); - const acceptedGameTick = - typeof commandAcceptedTick === 'number' && Number.isSafeInteger(commandAcceptedTick) - ? commandAcceptedTick - : coordinate - ? Number(coordinate.gameTick) - : gameTime!.tick; - const acceptedClockRevision = coordinate ? Number(coordinate.clockRevision) : gameTime!.revision; - const acceptedDeadlineGeneration = coordinate - ? Number(coordinate.deadlineGeneration) - : gameTime!.deadlineGeneration; await db.inputEvent.create({ data: { requestId, @@ -200,14 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { eventType: command.type, payload: asJson(command), actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null, - ...(acceptedGameTick === null ? {} : { acceptedGameTick: BigInt(acceptedGameTick) }), - ...(acceptedClockRevision === null || acceptedClockRevision === undefined - ? {} - : { acceptedClockRevision: BigInt(acceptedClockRevision) }), - ...(acceptedDeadlineGeneration === null || acceptedDeadlineGeneration === undefined - ? {} - : { acceptedDeadlineGeneration: BigInt(acceptedDeadlineGeneration) }), - ...(coordinate ? { createdAt: coordinate.wallAt } : {}), + // PostgreSQL owns created_at WALL_TIME. ENGINE assigns the + // authoritative game coordinate when the daemon claims it. }, }); } @@ -224,8 +201,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { } private async waitForResult(requestId: string, timeoutMs?: number): Promise { - const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs); - while (Date.now() < deadline) { + const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs); + while (performance.now() < deadline) { const event = await this.db.inputEvent.findUnique({ where: { requestId }, select: { status: true, result: true, error: true }, @@ -236,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { if (event?.status === 'FAILED') { throw new FailedTurnDaemonCommandError(requestId, event.error); } - await delay(Math.min(50, Math.max(1, deadline - Date.now()))); + await delay(Math.min(50, Math.max(1, deadline - performance.now()))); } return null; } diff --git a/app/game-api/src/inputEventBoundary.ts b/app/game-api/src/inputEventBoundary.ts index b868ab37..b0b29e9e 100644 --- a/app/game-api/src/inputEventBoundary.ts +++ b/app/game-api/src/inputEventBoundary.ts @@ -25,9 +25,6 @@ interface LockedInputEvent { status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED'; result: GamePrisma.JsonValue | null; attempts: number; - acceptedGameTick: bigint | null; - acceptedClockRevision: bigint | null; - acceptedDeadlineGeneration: bigint | null; } type InputEventOutcome = @@ -37,8 +34,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & { $executeRawUnsafe(query: string): Promise; }; -const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; - const canonicalJson = (value: unknown): string => JSON.stringify(value, (_key, entry: unknown) => { if (typeof entry === 'bigint') { @@ -106,9 +101,9 @@ const insertPendingIfAbsent = async ( ${options.actorUserId}, 'PENDING'::"InputEventStatus", 0, - (SELECT clock_tick FROM world_state ORDER BY id ASC LIMIT 1), - (SELECT clock_revision FROM world_state ORDER BY id ASC LIMIT 1), - (SELECT deadline_generation FROM world_state ORDER BY id ASC LIMIT 1), + NULL, + NULL, + NULL, CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ) ON CONFLICT (request_id) DO NOTHING @@ -126,10 +121,7 @@ const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise => { - await db.inputEvent.update({ - where: { requestId }, - data: { - payload: asJson(payloadIdentity), - status: 'PROCESSING', - result: GamePrisma.DbNull, - error: null, - attempts: { increment: 1 }, - lockedBy: null, - leaseUntil: null, - processingAt: new Date(), - processingGameTick: row.acceptedGameTick, - processingClockRevision: row.acceptedClockRevision, - processingDeadlineGeneration: row.acceptedDeadlineGeneration, - completedAt: null, - }, - }); + await db.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb), + status = 'PROCESSING'::"InputEventStatus", + result = NULL, + error = NULL, + attempts = attempts + 1, + locked_by = NULL, + lease_until = NULL, + processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + processing_game_tick = NULL, + processing_clock_revision = NULL, + processing_deadline_generation = NULL, + completed_at = NULL + WHERE request_id = ${requestId} + `); }; const markUnexpectedFailure = async ( @@ -198,6 +188,7 @@ const markUnexpectedFailure = async ( actorUserId: string | null; payloadIdentity: ApiInputPayloadIdentity; error: unknown; + acquireClockFence: boolean; } ): Promise => { if (!db.$transaction) return; @@ -205,7 +196,9 @@ const markUnexpectedFailure = async ( try { await db.$transaction(async (transaction) => { - await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + if (options.acquireClockFence) { + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + } await insertPendingIfAbsent(transaction, options); const row = await lockInputEvent(transaction, options.requestId); const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options); @@ -214,20 +207,19 @@ const markUnexpectedFailure = async ( // late failure recorder must never replace its durable success. return; } - await transaction.inputEvent.update({ - where: { requestId: options.requestId }, - data: { - payload: asJson(options.payloadIdentity), - status: 'FAILED', - result: GamePrisma.DbNull, - error: message, - attempts: { increment: 1 }, - lockedBy: null, - leaseUntil: null, - processingAt: new Date(), - completedAt: new Date(), - }, - }); + await transaction.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb), + status = 'FAILED'::"InputEventStatus", + result = NULL, + error = ${message}, + attempts = attempts + 1, + locked_by = NULL, + lease_until = NULL, + processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + WHERE request_id = ${options.requestId} + `); }); } catch { // Preserve the transaction failure that the caller actually observed. If @@ -242,10 +234,12 @@ export const executeInputEvent = async (options: { eventType: string; payload: unknown; actorUserId?: string | null; + acquireClockFence?: boolean; execute(db: DatabaseClient): Promise; }): Promise => { const { db, requestId, eventType, payload, execute } = options; const actorUserId = options.actorUserId ?? null; + const acquireClockFence = options.acquireClockFence !== false; const payloadIdentity = createApiInputPayloadIdentity(payload); if (!db.$transaction) { return execute(db); @@ -255,7 +249,9 @@ export const executeInputEvent = async (options: { let outcome: InputEventOutcome; try { outcome = await db.$transaction(async (transaction) => { - await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + if (acquireClockFence) { + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + } await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity }); const row = await lockInputEvent(transaction, requestId); const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity }); @@ -274,43 +270,48 @@ export const executeInputEvent = async (options: { throw new DuplicateInputEventError(requestId); } - await claimInputEvent(transaction, requestId, payloadIdentity, row); + await claimInputEvent(transaction, requestId, payloadIdentity); const savepointDb = transaction as SavepointDatabaseClient; await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`); businessStarted = true; try { const value = await execute(transaction); const durableResult = canonicalJsonValue(value); - await transaction.inputEvent.update({ - where: { requestId }, - data: { - status: 'SUCCEEDED', - result: asJson(durableResult), - error: null, - completedAt: new Date(), - }, - }); + await transaction.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET status = 'SUCCEEDED'::"InputEventStatus", + result = CAST(${JSON.stringify(durableResult)} AS jsonb), + error = NULL, + completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + WHERE request_id = ${requestId} + `); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); return { kind: 'executed', value }; } catch (error) { await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); const message = error instanceof Error ? error.message : 'Unknown API input event error.'; - await transaction.inputEvent.update({ - where: { requestId }, - data: { - status: 'FAILED', - result: GamePrisma.DbNull, - error: message, - completedAt: new Date(), - }, - }); + await transaction.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET status = 'FAILED'::"InputEventStatus", + result = NULL, + error = ${message}, + completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + WHERE request_id = ${requestId} + `); return { kind: 'failed', error }; } }); } catch (error) { if (businessStarted && !(error instanceof DuplicateInputEventError)) { - await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error }); + await markUnexpectedFailure(db, { + requestId, + eventType, + actorUserId, + payloadIdentity, + error, + acquireClockFence, + }); } throw error; } diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index 308f1d80..3b885522 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -1,9 +1,13 @@ -import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; -import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra'; +import { + enqueuePrivateMessageWebPush, + GamePrisma, + persistMessageEnvelope, + type MessageGameContext, +} from '@sammo-ts/infra'; import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic'; import type { DatabaseClient } from '../context.js'; -import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js'; +import { loadCurrentGameTime } from '../services/gameClock.js'; export interface MessageView { id: number; @@ -22,7 +26,9 @@ interface MessageRow { src: number; dest: number; time: Date; - valid_until: Date; + created_at_wall: Date; + action_status: string | null; + expires_game_tick: bigint | null; message: unknown; } @@ -48,70 +54,66 @@ const formatMessageTime = (value: Date): string => { )} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; }; -const messageValidityPredicate = (gameTime: CurrentGameTime) => { - if (gameTime.tick === null) { - // A legacy or partially migrated profile has no authoritative logical - // tick. Rows that already carry a tick still need the wall-time - // fallback used by the clock migration. - return GamePrisma.sql`valid_until > ${gameTime.now}`; - } - return GamePrisma.sql`( - (valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)}) - OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) - )`; -}; - -const toMessageView = (row: MessageRow): MessageView => { +const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => { const payload = parsePayload(row.message); + const actionStatus = typeof row.action_status === 'string' ? row.action_status : null; + const actionUnavailable = + actionStatus !== null && + (actionStatus !== 'PENDING' || + (row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick)); return { id: row.id, msgType: row.type, src: payload.src, dest: row.type === 'public' ? null : payload.dest, text: payload.text, - option: payload.option ?? null, - time: formatMessageTime(new Date(row.time)), + option: + actionUnavailable && payload.option && typeof payload.option === 'object' + ? { ...payload.option, used: true, invalid: true } + : (payload.option ?? null), + time: formatMessageTime(new Date(row.created_at_wall ?? row.time)), }; }; export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise => { - const gameTime = await loadCurrentGameTime(db); - const toTickOrNull = (date: Date): bigint | null => { - // Ref represents its unlimited 9999-12-31 message lifetime with the - // largest safe game tick instead of falling back to a wall-clock-only row. - if (date.getUTCFullYear() >= 9000) { - return BigInt(MAX_SAFE_GAME_TICK); + const action = draft.payload.option && Reflect.get(draft.payload.option, 'action'); + let gameContext: MessageGameContext | null = null; + if (typeof action === 'string' && action !== '') { + const gameTime = await loadCurrentGameTime(db); + if ( + gameTime.tick === null || + gameTime.revision === null || + gameTime.revision === undefined || + gameTime.deadlineGeneration === null || + gameTime.deadlineGeneration === undefined + ) { + throw new Error(`Actionable message ${action} requires an initialized game clock.`); } - try { - const tick = gameTime.dateToTick(date); - return tick === null ? null : BigInt(tick); - } catch { - return null; + let expiresGameTick: bigint | null = null; + if (draft.validUntil.getUTCFullYear() < 9000) { + const expires = gameTime.dateToTick(draft.validUntil); + if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`); + expiresGameTick = BigInt(expires); } - }; - const rows = await db.$queryRaw>` - INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message) - VALUES ( - ${draft.mailbox}, - ${draft.msgType}, - ${draft.srcId}, - ${draft.destId}, - ${draft.time}, - ${toTickOrNull(draft.time)}, - ${draft.validUntil}, - ${toTickOrNull(draft.validUntil)}, - CAST(${JSON.stringify(draft.payload)} AS jsonb) - ) - RETURNING id - `; - const id = rows[0]?.id; - if (!id) { - throw new Error('Failed to insert message row.'); + gameContext = { + occurredGameTick: BigInt(gameTime.tick), + clockRevision: BigInt(gameTime.revision), + deadlineGeneration: BigInt(gameTime.deadlineGeneration), + expiresGameTick, + }; } + const id = await persistMessageEnvelope(db, draft, gameContext); await enqueuePrivateMessageWebPush(db, draft, id); return id; }; +const loadMessageViews = async (db: DatabaseClient, rows: MessageRow[]): Promise => { + if (!rows.some((row) => typeof row.action_status === 'string')) return rows.map((row) => toMessageView(row, null)); + const gameTime = await loadCurrentGameTime(db); + const currentGameTick = gameTime.tick === null ? null : BigInt(gameTime.tick); + return rows.map((row) => toMessageView(row, currentGameTick)); +}; + export const fetchMessagesFromMailbox = async (params: { db: DatabaseClient; mailbox: number; @@ -120,19 +122,20 @@ export const fetchMessagesFromMailbox = async (params: { fromSeq: number; }): Promise => { const fromSeq = Math.max(params.fromSeq, 0); - const gameTime = await loadCurrentGameTime(params.db); const rows = await params.db.$queryRaw` - SELECT id, mailbox, type, src, dest, time, valid_until, message - FROM message - WHERE mailbox = ${params.mailbox} - AND type = ${params.msgType} - AND ${messageValidityPredicate(gameTime)} - AND id >= ${fromSeq} - ORDER BY id DESC + SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time, + m.created_at_wall, m.message, + ma.status AS action_status, ma.expires_game_tick + FROM message m + LEFT JOIN message_action ma ON ma.message_id = m.id + WHERE m.mailbox = ${params.mailbox} + AND m.type = ${params.msgType} + AND m.id >= ${fromSeq} + ORDER BY m.id DESC LIMIT ${params.limit} `; - return rows.map(toMessageView); + return loadMessageViews(params.db, rows); }; export const fetchOldMessagesFromMailbox = async (params: { @@ -142,28 +145,30 @@ export const fetchOldMessagesFromMailbox = async (params: { toSeq: number; limit: number; }): Promise => { - const gameTime = await loadCurrentGameTime(params.db); const rows = await params.db.$queryRaw` - SELECT id, mailbox, type, src, dest, time, valid_until, message - FROM message - WHERE mailbox = ${params.mailbox} - AND type = ${params.msgType} - AND ${messageValidityPredicate(gameTime)} - AND id < ${params.toSeq} - ORDER BY id DESC + SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time, + m.created_at_wall, m.message, + ma.status AS action_status, ma.expires_game_tick + FROM message m + LEFT JOIN message_action ma ON ma.message_id = m.id + WHERE m.mailbox = ${params.mailbox} + AND m.type = ${params.msgType} + AND m.id < ${params.toSeq} + ORDER BY m.id DESC LIMIT ${params.limit} `; - return rows.map(toMessageView); + return loadMessageViews(params.db, rows); }; export const fetchMessageById = async (db: DatabaseClient, id: number): Promise => { - const gameTime = await loadCurrentGameTime(db); const rows = await db.$queryRaw` - SELECT id, mailbox, type, src, dest, time, valid_until, message - FROM message - WHERE id = ${id} - AND ${messageValidityPredicate(gameTime)} + SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time, + m.created_at_wall, m.message, + ma.status AS action_status, ma.expires_game_tick + FROM message m + LEFT JOIN message_action ma ON ma.message_id = m.id + WHERE m.id = ${id} LIMIT 1 `; const row = rows[0]; @@ -172,20 +177,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise< id: row.id, mailbox: row.mailbox, msgType: row.type, - time: new Date(row.time), + time: new Date(row.created_at_wall ?? row.time), payload: parsePayload(row.message), }; }; export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise => { const gameTime = await loadCurrentGameTime(db); + if (gameTime.tick === null) throw new Error('Actionable message response requires an initialized game clock.'); const rows = await db.$queryRaw` - SELECT id, mailbox, type, src, dest, time, valid_until, message - FROM message - WHERE id = ${id} - AND ${messageValidityPredicate(gameTime)} + SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time, + m.created_at_wall, m.message, + ma.status AS action_status, ma.expires_game_tick + FROM message m + JOIN message_action ma ON ma.message_id = m.id + JOIN world_state world ON TRUE + WHERE m.id = ${id} + AND ma.status = 'PENDING' + AND (ma.expires_game_tick IS NULL OR ma.expires_game_tick > ${BigInt(gameTime.tick)}) + AND world.clock_phase IN ('RUNNING', 'MANUAL') + AND ma.clock_revision = world.clock_revision + AND ma.deadline_generation = world.deadline_generation LIMIT 1 - FOR UPDATE + FOR UPDATE OF m, ma, world `; const row = rows[0]; if (!row) return null; @@ -193,7 +207,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): id: row.id, mailbox: row.mailbox, msgType: row.type, - time: new Date(row.time), + time: new Date(row.created_at_wall ?? row.time), payload: parsePayload(row.message), }; }; @@ -202,16 +216,16 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); if (uniqueIds.length === 0) return; const gameTime = await loadCurrentGameTime(db); + if (gameTime.tick === null) throw new Error('Actionable message invalidation requires an initialized game clock.'); + await db.messageAction.updateMany({ + where: { messageId: { in: uniqueIds }, status: 'PENDING' }, + data: { status: 'RESOLVED', resolvedGameTick: BigInt(gameTime.tick) }, + }); await db.message.updateMany({ where: { id: { in: uniqueIds } }, data: { validUntil: gameTime.now, - // A partially migrated profile can still carry a legacy logical - // sentinel even while no authoritative clock exists. Replace it - // with an already-expired logical tick when expiring by wall time; - // NULL would fall back to the wall timestamp after clock recovery - // and could make the handled message visible again. - validUntilTick: gameTime.tick === null ? 0n : BigInt(gameTime.tick), + validUntilTick: BigInt(gameTime.tick), }, }); }; @@ -233,8 +247,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom END ) || jsonb_build_object('invalid', true), true - ) + ), + tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE id IN (${GamePrisma.join(uniqueIds)}) ` ); }; + +export const tombstoneMessagesWithinDeleteWindow = async ( + db: DatabaseClient, + authorityMessageId: number, + ids: number[] +): Promise => { + const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); + if (uniqueIds.length === 0) return []; + const rows = await db.$queryRaw>(GamePrisma.sql` + WITH wall AS ( + SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall + ), authority AS ( + SELECT m.id + FROM message m, wall + WHERE m.id = ${authorityMessageId} + AND m.tombstoned_at_wall IS NULL + AND m.delete_until_wall >= wall.now_wall + FOR UPDATE + ) + UPDATE message m + SET message = jsonb_set( + jsonb_set(m.message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true), + '{option}', + ( + CASE + WHEN jsonb_typeof(m.message->'option') = 'object' THEN m.message->'option' + ELSE '{}'::jsonb + END + ) || jsonb_build_object('invalid', true), + true + ), + tombstoned_at_wall = wall.now_wall + FROM wall + WHERE m.id IN (${GamePrisma.join(uniqueIds)}) + AND EXISTS (SELECT 1 FROM authority) + RETURNING m.id + `); + return rows.map(({ id }) => id).sort((left, right) => left - right); +}; diff --git a/app/game-api/src/router/auction/index.ts b/app/game-api/src/router/auction/index.ts index 9d70f5bd..8be7e4bc 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -60,10 +60,7 @@ export interface AuctionDetail { export const hasAuctionClosePassed = ( auction: { closeAt: Date; closeTick: bigint | null }, time: { now: Date; tick: number | null } -): boolean => - auction.closeTick !== null && time.tick !== null - ? auction.closeTick < BigInt(time.tick) - : auction.closeAt.getTime() < time.now.getTime(); +): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick); interface AuctionBidRow { id: number; @@ -433,7 +430,6 @@ export const auctionRouter = router({ auctionId: auction.id, generalId: general.id, amount: input.amount, - ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), tryExtendCloseDate: true, }); throwIfCommandRejected(result); @@ -448,7 +444,7 @@ export const auctionRouter = router({ const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const nextCloseAt = new Date(result.closeAt); await ctx.redis.zAdd(timerKeys.timerKey, [ - { score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, + { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) }, ]); return { ok: true }; @@ -511,7 +507,6 @@ export const auctionRouter = router({ auctionId: auction.id, generalId: general.id, amount: input.amount, - ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), tryExtendCloseDate: true, }); throwIfCommandRejected(result); @@ -526,7 +521,7 @@ export const auctionRouter = router({ const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const nextCloseAt = new Date(result.closeAt); await ctx.redis.zAdd(timerKeys.timerKey, [ - { score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, + { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) }, ]); return { ok: true }; @@ -650,7 +645,6 @@ export const auctionRouter = router({ auctionId: auction.id, generalId: general.id, amount: input.amount, - ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), tryExtendCloseDate: input.tryExtendCloseDate ?? false, }); throwIfCommandRejected(result); @@ -665,7 +659,7 @@ export const auctionRouter = router({ const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const nextCloseAt = new Date(result.closeAt); await ctx.redis.zAdd(timerKeys.timerKey, [ - { score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, + { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) }, ]); return { ok: true }; diff --git a/app/game-api/src/router/betting/index.ts b/app/game-api/src/router/betting/index.ts index 6fd53046..61e0f480 100644 --- a/app/game-api/src/router/betting/index.ts +++ b/app/game-api/src/router/betting/index.ts @@ -1,5 +1,5 @@ import { TRPCError } from '@trpc/server'; -import { GamePrisma } from '@sammo-ts/infra'; +import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra'; import { z } from 'zod'; import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; @@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters[0]['db']) => { return world; }; +interface BettingClockFenceRow { + currentYear: number; + currentMonth: number; + clockPhase: string; + clockRevision: bigint; + deadlineGeneration: bigint; +} + +const lockBettingClockFence = async (db: Parameters[0]['db']): Promise => { + await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK); + const rows = await db.$queryRaw(GamePrisma.sql` + SELECT current_year AS "currentYear", + current_month AS "currentMonth", + clock_phase AS "clockPhase", + clock_revision AS "clockRevision", + deadline_generation AS "deadlineGeneration" + FROM world_state + ORDER BY id ASC + LIMIT 1 + FOR UPDATE + `); + const world = rows[0]; + if (!world) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' }); + } + if (!['RUNNING', 'MANUAL', 'SUSPENDED'].includes(world.clockPhase)) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: `Nation betting is disabled while the game clock phase is ${world.clockPhase}.`, + }); + } + return world; +}; + export const bettingRouter = router({ - getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()) - .query(async ({ ctx, input }) => { + getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query( + async ({ ctx, input }) => { requireUserId(ctx.auth); await getMyGeneral(ctx); const [world, rows] = await Promise.all([ @@ -66,7 +100,8 @@ export const bettingRouter = router({ year: world.currentYear, month: world.currentMonth, }; - }), + } + ), getDetail: authedProcedure .input(z.object({ bettingId: z.number().int().positive() })) @@ -141,7 +176,7 @@ export const bettingRouter = router({ if (betting.finished) { throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' }); } - const world = await loadWorldDate(ctx.db); + const world = await lockBettingClockFence(ctx.db); const yearMonth = joinYearMonth(world.currentYear, world.currentMonth); if (betting.closeYearMonth <= yearMonth) { throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' }); diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index 2c7d4614..da94f7c7 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -15,7 +15,7 @@ import { import type { GameApiContext, GeneralRow, NationRow } from '../../context.js'; import { insertMessage } from '../../messages/store.js'; import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js'; -import { loadCurrentGameTime } from '../../services/gameClock.js'; +import { readDatabaseWallTime } from '../../services/wallClock.js'; import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; import { assertNationAccess, resolveNationPermission } from '../nation/shared.js'; @@ -306,8 +306,6 @@ export const diplomacyRouter = router({ nationColor: destNation.color, }, }; - const letterDate = (await loadCurrentGameTime(ctx.db)).now; - const created = await ctx.db.diplomacyLetter.create({ data: { srcNationId: srcNation.id, @@ -316,7 +314,6 @@ export const diplomacyRouter = router({ state: 'PROPOSED', textBrief: purifyDiplomacyHtml(input.brief), textDetail: purifyDiplomacyHtml(input.detail), - date: letterDate, srcSignerId: me.id, aux: aux as GamePrisma.InputJsonValue, }, @@ -332,7 +329,7 @@ export const diplomacyRouter = router({ src: srcTarget, dest: destTarget, text, - time: letterDate, + time: created.date, }); return { id: created.id }; @@ -371,7 +368,7 @@ export const diplomacyRouter = router({ ); const messageSrc = buildActorTarget(me, destNation); const messageDest = buildNationTarget(srcNation); - const messageTime = (await loadCurrentGameTime(ctx.db)).now; + const messageTime = await readDatabaseWallTime(ctx.db); const aux = asRecord(letter.aux); let messageText: string; if (input.agree) { @@ -458,7 +455,7 @@ export const diplomacyRouter = router({ ); const messageSrc = buildActorTarget(me, srcNation); const messageDest = buildNationTarget(destNation); - const messageTime = (await loadCurrentGameTime(ctx.db)).now; + const messageTime = await readDatabaseWallTime(ctx.db); const aux = asRecord(letter.aux); aux.reason = { who: me.id, @@ -519,7 +516,7 @@ export const diplomacyRouter = router({ const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation; const messageSrc = buildActorTarget(me, actorNation); const messageDest = buildNationTarget(otherNation); - const messageTime = (await loadCurrentGameTime(ctx.db)).now; + const messageTime = await readDatabaseWallTime(ctx.db); let resultState: 'ACTIVATED' | 'CANCELLED'; let messageText: string; diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index bb53ae20..cfffefdc 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -4,6 +4,11 @@ import { z } from 'zod'; import type { GameApiContext, WorldStateRow } from '../../context.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { asNumber, asRecord, asStringArray } from '@sammo-ts/common'; +import { + CLOCK_OPERATION_PERSISTENCE_LOCK, + GamePrisma, + acquireGameSchemaAdvisoryXactLock, +} from '@sammo-ts/infra'; import { isWarTraitKey, JOIN_PERSONALITY_TRAIT_KEYS, @@ -393,15 +398,12 @@ export const joinRouter = router({ if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const gameTime = await loadCurrentGameTime(ctx.db); const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId); const result = await ctx.turnDaemon.requestCommand({ type: 'selectPoolReserve', ...(commandRequestId ? { requestId: commandRequestId } : {}), userId, seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId, - acceptedGameAt: gameTime.now.toISOString(), - ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), }); return resolveSelectionReservationCommandResult(result); }), @@ -431,7 +433,6 @@ export const joinRouter = router({ }); } const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create'); - const gameTime = await loadCurrentGameTime(ctx.db); const result = await ctx.turnDaemon.requestCommand({ type: 'selectPoolCreate', ...(commandRequestId ? { requestId: commandRequestId } : {}), @@ -440,8 +441,6 @@ export const joinRouter = router({ uniqueName: input.uniqueName, personality: input.personality, seedOwnerIdentity: auth.user.legacyMemberNo ?? userId, - acceptedGameAt: gameTime.now.toISOString(), - ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), ...(selectedIcon ? { ownerPicture: selectedIcon.picture, @@ -471,15 +470,12 @@ export const joinRouter = router({ input.clientRequestId, 'reselect' ); - const gameTime = await loadCurrentGameTime(ctx.db); const result = await ctx.turnDaemon.requestCommand({ type: 'selectPoolReselect', ...(commandRequestId ? { requestId: commandRequestId } : {}), userId, ownerDisplayName: auth.user.displayName, uniqueName: input.uniqueName, - acceptedGameAt: gameTime.now.toISOString(), - ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), }); return resolveSelectionCommandResult(result, 'selectPoolReselect'); }), @@ -583,30 +579,46 @@ export const joinRouter = router({ message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', }); } - const worldState = await ctx.db.worldState.findFirst(); - if (!worldState) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'World state is not initialized.', - }); - } try { - const gameTime = await loadCurrentGameTime(ctx.db); - if (gameTime.tick === null) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'Game clock is not initialized.', + return await ctx.db.$transaction!(async (transaction) => { + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + const clockRows = await transaction.$queryRaw>(GamePrisma.sql` + SELECT clock_phase AS "clockPhase" + FROM world_state + ORDER BY id ASC + LIMIT 1 + FOR UPDATE + `); + if (!clockRows[0]) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.', + }); + } + const worldState = await transaction.worldState.findFirst(); + const gameTime = await loadCurrentGameTime(transaction); + if (!worldState || gameTime.tick === null) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Game clock is not initialized.', + }); + } + return reserveNpcPossessionCandidates({ + db: transaction, + worldState, + userId: auth.user.id, + ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id, + refresh: input.refresh, + keepIds: input.keepIds, + now: gameTime.now, + createdGameTick: gameTime.tick, }); - } - return await reserveNpcPossessionCandidates({ - db: ctx.db, - worldState, - userId: auth.user.id, - ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id, - refresh: input.refresh, - keepIds: input.keepIds, - now: gameTime.now, - acceptedGameTick: gameTime.tick, }); } catch (error) { if (error instanceof NpcPossessionError) { diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index c023ebc0..3ab42bde 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -5,7 +5,13 @@ import type { UserSanctions } from '@sammo-ts/common/auth/gameToken'; import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import type { GameApiContext } from '../../context.js'; -import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; +import { + accessLimitAuthedInputProcedure, + accessWallAuthedInputProcedure, + authedProcedure, + router, + wallAuthedProcedure, +} from '../../trpc.js'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, @@ -20,13 +26,12 @@ import { fetchOldMessagesFromMailbox, fetchMessageById, insertMessage, - tombstoneMessages, + tombstoneMessagesWithinDeleteWindow, type MessageView, } from '../../messages/store.js'; import { getOwnedGeneral } from '../shared/general.js'; import { resolveNationPermission } from '../nation/shared.js'; import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js'; -import { loadCurrentGameTime } from '../../services/gameClock.js'; const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); @@ -231,7 +236,7 @@ export const messagesRouter = router({ })), }; }), - readLatest: authedProcedure + readLatest: wallAuthedProcedure .input( z.object({ generalId: z.number().int().positive(), @@ -264,7 +269,7 @@ export const messagesRouter = router({ `; return { ok: true }; }), - delete: authedProcedure + delete: wallAuthedProcedure .input( z.object({ generalId: z.number().int().positive(), @@ -289,17 +294,16 @@ export const messagesRouter = router({ if (message.payload.option?.deletable === false) { throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' }); } - const { now } = await loadCurrentGameTime(ctx.db); - if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' }); - } const receiverMessageId = message.payload.option?.receiverMessageID; const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national'; const ids = [ message.id, ...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), ]; - await tombstoneMessages(ctx.db, ids); + const deletedIds = await tombstoneMessagesWithinDeleteWindow(ctx.db, message.id, ids); + if (!deletedIds.includes(message.id)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' }); + } const receiverMailbox = shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private' ? message.payload.dest.generalId @@ -309,7 +313,7 @@ export const messagesRouter = router({ ? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId : null; markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]); - return { ok: true, deletedIds: ids }; + return { ok: true, deletedIds }; }), respond: authedProcedure .input( @@ -420,7 +424,7 @@ export const messagesRouter = router({ ...messageBuckets, }; }), - send: accessAuthedInputProcedure( + send: accessWallAuthedInputProcedure( z.object({ generalId: z.number().int().positive(), mailbox: z.number().int(), @@ -436,7 +440,9 @@ export const messagesRouter = router({ } const src = await buildTargetFromGeneral(ctx.db, general); - const { now } = await loadCurrentGameTime(ctx.db); + // Compatibility-only projection. persistMessageEnvelope records and + // displays the authoritative PostgreSQL wall instant. + const now = new Date(); const validUntil = new Date('9999-12-31T00:00:00Z'); let msgType: MessageType; diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 8e1c24d0..745513c7 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -8,10 +8,10 @@ import type { TournamentState } from '../../tournament/types.js'; import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js'; import { buildTournamentKeys } from '../../tournament/keys.js'; import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js'; -import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; +import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; import { loadCurrentGameTime } from '../../services/gameClock.js'; -import { ensureActiveRedisClockFence } from '../../services/redisClockFence.js'; +import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js'; import { loadClockAdminStatus } from '../../services/clockReadiness.js'; const hasAdminRole = (roles: string[], profileName: string): boolean => { @@ -64,7 +64,7 @@ const withTournamentClockMutation = async ( }); } const clockContext: TournamentClockContext = { - phase: 'RUNNING', + phase: fence.phase, revision: fence.revision, deadlineGeneration: fence.generation, dateToTick: gameTime.dateToTick, @@ -72,6 +72,37 @@ const withTournamentClockMutation = async ( return store.withClockContext(clockContext, () => store.withMutationLock(operation)); }; +const withTournamentBetClockMutation = async ( + ctx: { + db: Parameters[0]; + redis: Parameters[0]; + profile: { name: string }; + }, + store: TournamentStore, + operation: () => Promise +): Promise => { + const gameTime = await loadCurrentGameTime(ctx.db); + const fence = await ensureBettingRedisClockFence(ctx.redis, ctx.profile.name, gameTime); + if (!fence) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Clock reconciliation is incomplete; tournament betting is disabled.', + }); + } + return store.withClockContext( + { + phase: fence.phase, + revision: fence.revision, + deadlineGeneration: fence.generation, + dateToTick: gameTime.dateToTick, + }, + () => store.withMutationLock(operation) + ); +}; + +const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined => + requestId ? `${requestId}:tournamentBet:${step}` : undefined; + const zTournamentState = z.object({ stage: z.number().int().min(0), phase: z.number().int().min(0), @@ -544,7 +575,10 @@ export const tournamentRouter = router({ return { ok: true }; }); }), - placeBet: authedProcedure + // This route delegates its game mutations to durable ENGINE input events. + // Wrapping it in the API input-event transaction would hold the clock + // advisory lock while waiting for the daemon to claim the child event. + placeBet: engineAuthedProcedure .input( z.object({ targetId: z.number().int().positive(), @@ -554,7 +588,7 @@ export const tournamentRouter = router({ .mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - return withTournamentClockMutation(ctx, store, async () => { + return withTournamentBetClockMutation(ctx, store, async () => { const state = await store.getState(); if (!state || state.stage !== 6) { throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' }); @@ -589,6 +623,7 @@ export const tournamentRouter = router({ const adjustResult = await ctx.turnDaemon.requestCommand({ type: 'adjustGeneralResources', + requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'), reason: 'tournamentBet', adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }], }); @@ -604,6 +639,7 @@ export const tournamentRouter = router({ const rankResult = await ctx.turnDaemon.requestCommand({ type: 'adjustGeneralMeta', + requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'), reason: 'tournamentBet', adjustments: [ { @@ -615,6 +651,7 @@ export const tournamentRouter = router({ if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) { await ctx.turnDaemon.requestCommand({ type: 'adjustGeneralResources', + requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'), reason: 'tournamentBetRollback', adjustments: [{ generalId: general.id, goldDelta: input.amount }], }); @@ -631,11 +668,13 @@ export const tournamentRouter = router({ await Promise.all([ ctx.turnDaemon.requestCommand({ type: 'adjustGeneralResources', + requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'), reason: 'tournamentBetRollback', adjustments: [{ generalId: general.id, goldDelta: input.amount }], }), ctx.turnDaemon.requestCommand({ type: 'adjustGeneralMeta', + requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'), reason: 'tournamentBetRollback', adjustments: [ { diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index af09b652..7ec78794 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -102,18 +102,16 @@ export const hasPollEnded = ( time: CurrentGameTime ): boolean => Boolean(poll.closed_at) || - (poll.end_tick !== null && time.tick !== null - ? poll.end_tick < BigInt(time.tick) - : Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime())); + Boolean( + poll.end_at && + (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)) + ); const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => { if (!date) return null; - try { - const tick = time.dateToTick(date); - return tick === null ? null : BigInt(tick); - } catch { - return null; - } + const tick = time.dateToTick(date); + if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.'); + return BigInt(tick); }; type VoteListRow = { @@ -358,7 +356,6 @@ export const voteRouter = router({ voteId: input.voteId, generalId: general.id, selection: sortedSelection, - ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), }); throwIfCommandRejected(rewardResult); @@ -399,8 +396,6 @@ export const voteRouter = router({ ? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } }) : null; const nationName = nation?.name ?? '재야'; - const createdAt = new Date(); - await ctx.db.$queryRaw(GamePrisma.sql` INSERT INTO vote_comment ( vote_id, @@ -418,7 +413,7 @@ export const voteRouter = router({ ${general.name}, ${nationName}, ${input.text}, - ${createdAt} + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ) `); @@ -451,7 +446,6 @@ export const voteRouter = router({ if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } - const operationalAt = new Date(); let multipleOptions = input.multipleOptions; if (multipleOptions < 0) { @@ -464,7 +458,8 @@ export const voteRouter = router({ if (input.closePrevious) { await ctx.db.$queryRaw(GamePrisma.sql` UPDATE vote_poll - SET closed_at = ${gameTime.now}, updated_at = ${operationalAt} + SET closed_at = ${gameTime.now}, + updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE closed_at IS NULL `); } @@ -497,8 +492,8 @@ export const voteRouter = router({ ${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${endAt}, ${toGameTickOrNull(gameTime, endAt)}, - ${operationalAt}, - ${operationalAt} + CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ) `); @@ -573,7 +568,6 @@ export const voteRouter = router({ if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } - const updatedAt = new Date(); if ( input.title === undefined && @@ -596,7 +590,7 @@ export const voteRouter = router({ reveal_mode = COALESCE(${input.revealMode}, reveal_mode), end_at = ${endAt ?? poll.end_at}, end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick}, - updated_at = ${updatedAt} + updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE id = ${input.voteId} `); @@ -609,10 +603,10 @@ export const voteRouter = router({ .input(z.object({ voteId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { const gameTime = await loadCurrentGameTime(ctx.db); - const updatedAt = new Date(); const rows = await ctx.db.$queryRaw>(GamePrisma.sql` UPDATE vote_poll - SET closed_at = ${gameTime.now}, updated_at = ${updatedAt} + SET closed_at = ${gameTime.now}, + updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE id = ${input.voteId} RETURNING id `); diff --git a/app/game-api/src/services/redisClockFence.ts b/app/game-api/src/services/redisClockFence.ts index 783bb4a4..0b192c90 100644 --- a/app/game-api/src/services/redisClockFence.ts +++ b/app/game-api/src/services/redisClockFence.ts @@ -1,4 +1,5 @@ import type { CurrentGameTime } from './gameClock.js'; +import type { GameClockPhase } from '@sammo-ts/common'; interface ClockFenceRedis { eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; @@ -26,30 +27,58 @@ export interface ActiveRedisClockFence { phaseKey: string; revision: number; generation: number; + phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED'; } -export const ensureActiveRedisClockFence = async ( +type MutableProjectionPhase = ActiveRedisClockFence['phase']; + +const ensureRedisClockFence = async ( redis: ClockFenceRedis, profileName: string, - gameTime: CurrentGameTime + gameTime: CurrentGameTime, + allowedPhases: readonly GameClockPhase[] ): Promise => { if ( - gameTime.phase !== 'RUNNING' || + !gameTime.phase || + !allowedPhases.includes(gameTime.phase) || + (gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') || !Number.isSafeInteger(gameTime.revision) || !Number.isSafeInteger(gameTime.deadlineGeneration) ) { return null; } + const phase: MutableProjectionPhase = gameTime.phase; const fence: ActiveRedisClockFence = { activeRevisionKey: `sammo:${profileName}:clock:active-revision`, deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`, phaseKey: `sammo:${profileName}:clock:phase`, revision: gameTime.revision!, generation: gameTime.deadlineGeneration!, + phase, }; const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, { keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey], - arguments: [String(fence.revision), String(fence.generation), 'RUNNING'], + arguments: [String(fence.revision), String(fence.generation), phase], }); return Number(result) === 1 || Number(result) === 2 ? fence : null; }; + +export const ensureActiveRedisClockFence = async ( + redis: ClockFenceRedis, + profileName: string, + gameTime: CurrentGameTime +): Promise => { + return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']); +}; + +/** + * User betting is allowed against a frozen tournament deadline while the game + * clock is suspended. Stage progression and settlement continue to use the + * RUNNING-only helper above. + */ +export const ensureBettingRedisClockFence = async ( + redis: ClockFenceRedis, + profileName: string, + gameTime: CurrentGameTime +): Promise => + ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING', 'MANUAL', 'SUSPENDED']); diff --git a/app/game-api/src/services/turnEngineStatus.ts b/app/game-api/src/services/turnEngineStatus.ts index 7b4dfde4..04172a17 100644 --- a/app/game-api/src/services/turnEngineStatus.ts +++ b/app/game-api/src/services/turnEngineStatus.ts @@ -1,32 +1,37 @@ +import { performance } from 'node:perf_hooks'; + import { gatewayProfileCapabilities } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; import type { ProfileStatusSource } from '../auth/profileStatusSource.js'; interface TurnDaemonLeaseSource { - turnDaemonLease: { - findUnique(input: { - where: { profile: string }; - select: { leaseUntil: true }; - }): Promise<{ leaseUntil: Date } | null>; - }; + $queryRaw(query: GamePrisma.Sql): Promise; } export const loadTurnEngineRunning = async ( source: ProfileStatusSource | undefined, db: TurnDaemonLeaseSource, profileName: string, - now = new Date() + now?: Date ): Promise => { if (!source) return null; try { const status = await source.get(profileName); if (status === null) return null; if (!gatewayProfileCapabilities(status).turnsRunning) return false; - const lease = await db.turnDaemonLease.findUnique({ - where: { profile: profileName }, - select: { leaseUntil: true }, - }); - return lease !== null && lease.leaseUntil.getTime() > now.getTime(); + const wallNow = now + ? GamePrisma.sql`${now}` + : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`; + const rows = await db.$queryRaw>(GamePrisma.sql` + SELECT EXISTS ( + SELECT 1 + FROM turn_daemon_lease + WHERE profile = ${profileName} + AND lease_until > ${wallNow} + ) AS running + `); + return rows[0]?.running ?? false; } catch { return null; } @@ -42,7 +47,7 @@ export class CachedTurnEngineStatus { private readonly db: TurnDaemonLeaseSource, private readonly profileName: string, private readonly cacheMs = 2_000, - private readonly now = () => Date.now() + private readonly now = () => performance.now() ) {} get(): Promise { diff --git a/app/game-api/src/services/wallClock.ts b/app/game-api/src/services/wallClock.ts new file mode 100644 index 00000000..5a3a80c5 --- /dev/null +++ b/app/game-api/src/services/wallClock.ts @@ -0,0 +1,13 @@ +import { GamePrisma } from '@sammo-ts/infra'; + +import type { DatabaseClient } from '../context.js'; + +/** Reads the authoritative PostgreSQL UTC wall instant for business rules. */ +export const readDatabaseWallTime = async (db: Pick): Promise => { + const rows = await db.$queryRaw>(GamePrisma.sql` + SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow" + `); + const wallNow = rows[0]?.wallNow; + if (!wallNow) throw new Error('Failed to read PostgreSQL wall time.'); + return new Date(wallNow); +}; diff --git a/app/game-api/src/services/webPushOutboxWorker.ts b/app/game-api/src/services/webPushOutboxWorker.ts index c2cc6929..9f617c7d 100644 --- a/app/game-api/src/services/webPushOutboxWorker.ts +++ b/app/game-api/src/services/webPushOutboxWorker.ts @@ -1,4 +1,5 @@ import { createHmac, randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common'; import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; @@ -55,10 +56,13 @@ export class WebPushOutboxWorker { `); if (rows.length === 0) return []; const ids = rows.map((row) => row.id); - await tx.webPushOutbox.updateMany({ - where: { id: { in: ids } }, - data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } }, - }); + await tx.$executeRaw(GamePrisma.sql` + UPDATE "web_push_outbox" + SET "locked_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + "lock_owner" = ${this.owner}, + "attempts" = "attempts" + 1 + WHERE "id" IN (${GamePrisma.join(ids)}) + `); return tx.webPushOutbox.findMany({ where: { id: { in: ids }, lockOwner: this.owner }, orderBy: { id: 'asc' }, @@ -66,11 +70,18 @@ export class WebPushOutboxWorker { }); for (const event of claimed) { - if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) { - await this.db.webPushOutbox.updateMany({ - where: { id: event.id, lockOwner: this.owner }, - data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null }, - }); + const expired = await this.db.$executeRaw(GamePrisma.sql` + UPDATE "web_push_outbox" + SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + "locked_at" = NULL, + "lock_owner" = NULL, + "last_error" = NULL + WHERE "id" = ${event.id} + AND "lock_owner" = ${this.owner} + AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + - ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond' + `); + if (expired > 0) { continue; } try { @@ -94,27 +105,32 @@ export class WebPushOutboxWorker { signal: AbortSignal.timeout(5_000), }); if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`); - await this.db.webPushOutbox.updateMany({ - where: { id: event.id, lockOwner: this.owner }, - data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null }, - }); + await this.db.$executeRaw(GamePrisma.sql` + UPDATE "web_push_outbox" + SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + "locked_at" = NULL, + "lock_owner" = NULL, + "last_error" = NULL + WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner} + `); } catch (error) { const attempts = event.attempts; const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8)); - await this.db.webPushOutbox.updateMany({ - where: { id: event.id, lockOwner: this.owner }, - data: { - availableAt: new Date(Date.now() + delaySeconds * 1_000), - lockedAt: null, - lockOwner: null, - lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500), - }, - }); + const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500); + await this.db.$executeRaw(GamePrisma.sql` + UPDATE "web_push_outbox" + SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + + ${delaySeconds * 1_000} * INTERVAL '1 millisecond', + "locked_at" = NULL, + "lock_owner" = NULL, + "last_error" = ${errorText} + WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner} + `); this.onError(error); } } - if (Date.now() >= this.nextPruneAt) { - this.nextPruneAt = Date.now() + 60_000; + if (performance.now() >= this.nextPruneAt) { + this.nextPruneAt = performance.now() + 60_000; await this.db.$executeRaw(GamePrisma.sql` WITH expired AS ( SELECT "id" diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index 59abeea7..6ea917f4 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common'; import { z } from 'zod'; @@ -136,7 +137,7 @@ const parseProjection = (raw: string | null, key: string, schema: z.ZodType { const resolveScheduledBaseMs = (state: TournamentState): number => { const scheduled = new Date(state.nextAt).getTime(); - return Number.isFinite(scheduled) ? scheduled : Date.now(); + if (!Number.isFinite(scheduled)) { + throw new Error('Tournament GAME_TIME schedule is invalid.'); + } + return scheduled; }; export const resolveNextAt = (state: TournamentState): string => diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index a67aa250..154837c0 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -62,61 +62,68 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => { export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string => `${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`; -const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => { - if (type !== 'mutation' || !ctx.db.$transaction) { - return next(); - } +const createInputEventMiddleware = (acquireClockFence: boolean) => + t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => { + if (type !== 'mutation' || !ctx.db.$transaction) { + return next(); + } - const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex); - const payload = await getRawInput(); - const changeJournal = new ChangeJournal(); - let journalPersisted = false; - let executedResult: Awaited> | undefined; - try { - const response = await executeInputEvent({ - db: ctx.db, - requestId, - eventType: path, - payload, - actorUserId: ctx.auth?.user.id, - execute: async (transaction) => { - const result = await next({ - ctx: { - ...ctx, - db: transaction, - changeJournal, - turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId), - }, - }); - if (!result.ok) { - throw result.error; - } - journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot())); - executedResult = result; - return result.data; - }, - }); - if (journalPersisted) { - ctx.readModelOutbox?.wake(); - } - if (executedResult) { - return executedResult; - } - return { - marker: middlewareMarker, - ok: true, - data: response, - }; - } catch (error) { - if (error instanceof DuplicateInputEventError) { - throw new TRPCError({ - code: 'CONFLICT', - message: error.message, + const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex); + const payload = await getRawInput(); + const changeJournal = new ChangeJournal(); + let journalPersisted = false; + let executedResult: Awaited> | undefined; + try { + const response = await executeInputEvent({ + db: ctx.db, + requestId, + eventType: path, + payload, + actorUserId: ctx.auth?.user.id, + acquireClockFence, + execute: async (transaction) => { + const result = await next({ + ctx: { + ...ctx, + db: transaction, + changeJournal, + turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId), + }, + }); + if (!result.ok) { + throw result.error; + } + journalPersisted = Boolean( + await writeReadModelChangeJournal(transaction, changeJournal.snapshot()) + ); + executedResult = result; + return result.data; + }, }); + if (journalPersisted) { + ctx.readModelOutbox?.wake(); + } + if (executedResult) { + return executedResult; + } + return { + marker: middlewareMarker, + ok: true, + data: response, + }; + } catch (error) { + if (error instanceof DuplicateInputEventError) { + throw new TRPCError({ + code: 'CONFLICT', + message: error.message, + }); + } + throw error; } - throw error; - } -}); + }); + +const inputEventMiddleware = createInputEventMiddleware(true); +const wallInputEventMiddleware = createInputEventMiddleware(false); const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => { // 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다. @@ -180,10 +187,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next }) export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); +export const wallProcedure = t.procedure.use(wallInputEventMiddleware); export const authedProcedure: typeof procedure = t.procedure .use(requireAuthMiddleware) .use(generalActivityMiddleware) .use(inputEventMiddleware); +export const wallAuthedProcedure: typeof procedure = t.procedure + .use(requireAuthMiddleware) + .use(generalActivityMiddleware) + .use(wallInputEventMiddleware); // Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation // transaction보다 먼저 별도 저장된다. access middleware를 input-event보다 @@ -234,6 +246,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) => .use(generalAccessEndpointMiddleware) .use(generalActivityMiddleware) .use(inputEventMiddleware); +export const accessWallAuthedInputProcedure: typeof procedure.input = (input) => + t.procedure + .use(requireAuthMiddleware) + .input(input) + .use(generalAccessEndpointMiddleware) + .use(generalActivityMiddleware) + .use(wallInputEventMiddleware); export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) => t.procedure .use(requireAuthMiddleware) diff --git a/app/game-api/test/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts index 8735dbb9..82ab918d 100644 --- a/app/game-api/test/auctionRouter.test.ts +++ b/app/game-api/test/auctionRouter.test.ts @@ -96,6 +96,7 @@ const buildContext = (options: { ok: true as const, auctionId: 91, closeAt: '2026-07-27T00:00:00.000Z', + closeTick: 200, }; } return { @@ -103,6 +104,7 @@ const buildContext = (options: { ok: true as const, auctionId: 91, closeAt: '2026-07-27T00:00:00.000Z', + closeTick: 200, }; }); const queryRaw = vi.fn(options.queryRaw ?? (async () => [])); @@ -112,14 +114,10 @@ const buildContext = (options: { currentYear: 200, currentMonth: 1, tickSeconds: 3600, - ...(options.clockTick === undefined - ? {} - : { - clockBaseTime: new Date('2026-07-26T00:00:00.000Z'), - clockTick: BigInt(options.clockTick), - clockMode: 'manual', - clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'), - }), + clockBaseTime: new Date('2026-07-26T00:00:00.000Z'), + clockTick: BigInt(options.clockTick ?? 100), + clockMode: 'manual', + clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'), config: { const: { auctionName: ['청룡', '백호', '주작', '현무'], @@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => { tick: 72_000_001, }) ).toBe(true); - expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false); + expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(true); }); it('rejects unauthenticated auction reads', async () => { @@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => { auctionId: 31, generalId: 7, amount: 110, - acceptedGameTick: 100, tryExtendCloseDate: false, }); }); @@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => { detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false }, status: 'OPEN', closeAt: new Date(Date.now() + 60 * 60_000), + closeTick: 200n, }, ]; } @@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => { auctionId: 31, generalId: 7, amount: 500, - acceptedGameTick: 100, tryExtendCloseDate: true, }); }); diff --git a/app/game-api/test/auctionWorker.integration.test.ts b/app/game-api/test/auctionWorker.integration.test.ts index 33fb3f49..ec0ad77f 100644 --- a/app/game-api/test/auctionWorker.integration.test.ts +++ b/app/game-api/test/auctionWorker.integration.test.ts @@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => { detail: { amount: 100 }, status, closeAt, + closeTick: 0n, ...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}), }, }); @@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => { return auction; }; - const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => - buildAuctionFinalizeRequestId(auction.id, { + const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => { + if (auction.closeTick === null || auction.closeTick === undefined) { + throw new Error(`auction ${auction.id} fixture requires closeTick`); + } + return buildAuctionFinalizeRequestId(auction.id, { closeAt: auction.closeAt, - closeTick: auction.closeTick ?? null, + closeTick: auction.closeTick, }); + }; const memoryRedis = () => ({ zRangeByScore: vi.fn(async () => []), @@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => { historyKey: 'history', id: String(auction.id), nowMs: Date.now(), + nowTick: 0, }) ).resolves.toBe('PENDING'); await expect( @@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => { historyKey: 'history', id: String(auction.id), nowMs: Date.now(), + nowTick: 0, }) ).resolves.toBe('PENDING'); @@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => { historyKey: 'history', id: String(auction.id), nowMs: Date.now(), + nowTick: 0, }) ).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`); @@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => { requestId, target: 'ENGINE', eventType: 'auctionFinalize', - payload: { type: 'auctionFinalize', requestId, auctionId: auction.id }, + payload: { + type: 'auctionFinalize', + requestId, + auctionId: auction.id, + expectedCloseAt: auction.closeAt.toISOString(), + expectedCloseTick: Number(auction.closeTick), + }, status: 'FAILED', attempts: 3, error: 'simulated terminal failure', @@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => { historyKey: 'history', id: String(auction.id), nowMs: Date.now(), + nowTick: 0, }) ).resolves.toBe('PENDING'); await expect( @@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => { historyKey: 'history', id: String(auction.id), nowMs: Date.now(), + nowTick: 0, }) ).resolves.toBe('PENDING'); await expect( @@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => { historyKey: 'history', id: String(auction.id), nowMs: Date.now(), + nowTick: 0, }) ).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`); }); it('creates a new generation after an earlier close was extended', async () => { const auction = await createAuction('OPEN'); - const priorRequestId = `auction:finalize:${auction.id}:${auction.closeAt.getTime() - 300_000}`; + const priorRequestId = `auction:finalize:${auction.id}:tick:-1`; await connector.prisma.inputEvent.create({ data: { requestId: priorRequestId, @@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => { historyKey: 'history', id: String(auction.id), nowMs: Date.now(), + nowTick: 0, }) ).resolves.toBe('PENDING'); @@ -431,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => { amount: 200, eventId: `auction-durable-bid:${auction.id}`, eventAt: new Date(), + occurredGameTick: 0n, + requestedAtWall: new Date(), }, }); const requestId = requestIdFor(auction); @@ -441,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => { historyKey: 'history', id: String(auction.id), nowMs: Date.now(), + nowTick: 0, }); const world = new InMemoryTurnWorld(state, snapshot, { schedule }); @@ -509,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => { detail: { remainCloseDateExtensionCnt: 1 }, status: 'OPEN', closeAt: logicalPastCloseAt, + closeTick: 0n, }, }); extensionAuctionId = extensionAuction.id; @@ -521,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => { amount: 50, eventId: `auction-extension-bid:${extensionAuction.id}`, eventAt: new Date(), + occurredGameTick: 0n, + requestedAtWall: new Date(), meta: { tryExtendCloseDate: true }, }, }); @@ -532,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => { historyKey: 'history', id: String(extensionAuction.id), nowMs: Date.now(), + nowTick: 0, }); let reopened: { status: string; closeAt: Date } | null = null; diff --git a/app/game-api/test/auctionWorker.test.ts b/app/game-api/test/auctionWorker.test.ts index b55ab267..cfae3ee9 100644 --- a/app/game-api/test/auctionWorker.test.ts +++ b/app/game-api/test/auctionWorker.test.ts @@ -33,7 +33,7 @@ const buildDb = (options: { $executeRaw: vi.fn(async () => options.updated), auction: { findUnique: vi.fn(async () => - options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null + options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? 72_000_000n } : null ), }, inputEvent: { @@ -233,11 +233,12 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), + nowTick: 36_000_000, }) ).resolves.toBe('RESCHEDULED'); expect(redis.zAdd).toHaveBeenCalledTimes(1); - expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]); + expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]); expect(transaction.inputEvent.create).not.toHaveBeenCalled(); }); @@ -267,7 +268,7 @@ describe('auction worker clock-shift race', () => { it('leaves OPEN untouched and creates one durable command before recording history', async () => { const redis = buildRedis(); const closeAt = new Date('2026-07-30T11:00:00.000Z'); - const requestId = `auction:finalize:7:${closeAt.getTime()}`; + const requestId = 'auction:finalize:7:tick:72000000'; const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } }); const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime(); @@ -279,6 +280,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs, + nowTick: 72_000_000, }) ).resolves.toBe('PENDING'); @@ -293,6 +295,7 @@ describe('auction worker clock-shift race', () => { requestId, auctionId: 7, expectedCloseAt: closeAt.toISOString(), + expectedCloseTick: 72_000_000, }, }, }); @@ -324,7 +327,6 @@ describe('auction worker clock-shift race', () => { requestId, target: 'ENGINE', eventType: 'auctionFinalize', - acceptedGameTick: 72_000_000n, payload: { type: 'auctionFinalize', requestId, @@ -351,6 +353,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), + nowTick: 72_000_000, }) ).resolves.toBe('PENDING'); @@ -363,7 +366,7 @@ describe('auction worker clock-shift race', () => { it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => { const redis = buildRedis(); const closeAt = new Date('2026-07-30T11:00:00.000Z'); - const requestId = `auction:finalize:7:${closeAt.getTime()}`; + const requestId = 'auction:finalize:7:tick:72000000'; const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt }, @@ -377,6 +380,7 @@ describe('auction worker clock-shift race', () => { requestId, auctionId: 7, expectedCloseAt: closeAt.toISOString(), + expectedCloseTick: 72_000_000, }, status: 'PENDING', result: null, @@ -392,6 +396,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), + nowTick: 72_000_000, }) ).resolves.toBe('PENDING'); expect(transaction.inputEvent.create).not.toHaveBeenCalled(); @@ -412,6 +417,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs: logicalNowMs, + nowTick: 72_000_000, historyNowMs: operationalNowMs, }); @@ -421,12 +427,12 @@ describe('auction worker clock-shift race', () => { it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => { const redis = buildRedis(); const closeAt = new Date('2026-07-30T11:00:00.000Z'); - const requestId = `auction:finalize:7:${closeAt.getTime()}`; + const requestId = 'auction:finalize:7:tick:72000000'; const existingEvent = { requestId, target: 'ENGINE' as const, eventType: 'auctionFinalize', - payload: { type: 'auctionFinalize', requestId, auctionId: 7 }, + payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 }, status: 'PENDING' as const, result: null, }; @@ -444,6 +450,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), + nowTick: 72_000_000, }) ).resolves.toBe('PENDING'); @@ -456,7 +463,7 @@ describe('auction worker clock-shift race', () => { it('creates one bounded successor after a terminal event failure', async () => { const redis = buildRedis(); const closeAt = new Date('2026-07-30T11:00:00.000Z'); - const requestId = `auction:finalize:7:${closeAt.getTime()}`; + const requestId = 'auction:finalize:7:tick:72000000'; const retryRequestId = `${requestId}:retry:1`; const { db, transaction } = buildDb({ updated: 0, @@ -466,7 +473,7 @@ describe('auction worker clock-shift race', () => { requestId, target: 'ENGINE', eventType: 'auctionFinalize', - payload: { type: 'auctionFinalize', requestId, auctionId: 7 }, + payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 }, status: 'FAILED', result: null, }, @@ -481,6 +488,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), + nowTick: 72_000_000, }) ).resolves.toBe('PENDING'); @@ -494,6 +502,7 @@ describe('auction worker clock-shift race', () => { requestId: retryRequestId, auctionId: 7, expectedCloseAt: closeAt.toISOString(), + expectedCloseTick: 72_000_000, }, }, }); @@ -501,19 +510,23 @@ describe('auction worker clock-shift race', () => { it('uses the close deadline as the generation so a reopened auction gets a new command', async () => { const redis = buildRedis(); - const previousCloseAt = new Date('2026-07-30T11:00:00.000Z'); const closeAt = new Date('2026-07-30T11:30:00.000Z'); - const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`; - const requestId = `auction:finalize:7:${closeAt.getTime()}`; + const previousRequestId = 'auction:finalize:7:tick:36000000'; + const requestId = 'auction:finalize:7:tick:72000000'; const { db, transaction } = buildDb({ updated: 0, - auction: { status: 'OPEN', closeAt }, + auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n }, existingEvents: [ { requestId: previousRequestId, target: 'ENGINE', eventType: 'auctionFinalize', - payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 }, + payload: { + type: 'auctionFinalize', + requestId: previousRequestId, + auctionId: 7, + expectedCloseTick: 36_000_000, + }, status: 'SUCCEEDED', result: { type: 'auctionFinalize', ok: false, auctionId: 7 }, }, @@ -528,6 +541,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), + nowTick: 72_000_000, }) ).resolves.toBe('PENDING'); @@ -541,6 +555,7 @@ describe('auction worker clock-shift race', () => { requestId, auctionId: 7, expectedCloseAt: closeAt.toISOString(), + expectedCloseTick: 72_000_000, }, }, }); @@ -560,6 +575,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), + nowTick: 72_000_000, }) ).rejects.toThrow('event insert failed'); diff --git a/app/game-api/test/diplomacyRouter.test.ts b/app/game-api/test/diplomacyRouter.test.ts index 935ba208..ec55e66b 100644 --- a/app/game-api/test/diplomacyRouter.test.ts +++ b/app/game-api/test/diplomacyRouter.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; @@ -88,7 +87,7 @@ const storedLetter = { }; const buildContext = (officerLevel = 12, letter: Record = storedLetter) => { - const create = vi.fn(async () => ({ id: 9 })); + const create = vi.fn(async () => ({ id: 9, date: new Date('2026-07-31T00:00:00.000Z') })); let messageId = 100; const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]); const db = { @@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => { textBrief: '

공개

', textDetail: '
  • 조건
자료', - date: new Date('0185-01-01T00:00:00.000Z'), }), }); expect(fixture.queryRaw).toHaveBeenCalledTimes(2); - expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual( - expect.arrayContaining([9002, 'diplomacy', 9001, 9002]) - ); - expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy'])); - expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK)); - expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text'))) - .toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.'); }); it('purifies legacy stored rows on every read while preserving secret redaction', async () => { diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 41b8f4cd..2d8f4dd5 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -326,18 +326,14 @@ describe('general access tracking', () => { : [{ id: 41 }]; }), $executeRaw: vi.fn(async (query: unknown) => { - if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) { + const sql = (query as { sql?: string }).sql ?? ''; + if (sql.includes('INSERT INTO input_event')) { events.push('input-event-create'); } + if (sql.includes("status = 'FAILED'")) events.push('input-event-failed'); return 1; }), $executeRawUnsafe: vi.fn(async () => 0), - inputEvent: { - update: vi.fn(async (args: { data: { status: string } }) => { - if (args.data.status === 'FAILED') events.push('input-event-failed'); - return {}; - }), - }, }; const db = { general: { diff --git a/app/game-api/test/idempotentTransport.test.ts b/app/game-api/test/idempotentTransport.test.ts index 04891fe6..13f55968 100644 --- a/app/game-api/test/idempotentTransport.test.ts +++ b/app/game-api/test/idempotentTransport.test.ts @@ -91,7 +91,7 @@ describe('IdempotentTurnDaemonTransport', () => { } }); - it('reuses a successful vote event when only the retry acceptance tick has changed', async () => { + it('reuses a rolling-upgrade vote event after legacy acceptance coordinates are removed', async () => { const persistedPayload = { type: 'voteReward' as const, requestId: 'vote-reward', @@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => { selection: [0], acceptedGameTick: 100, }; + const currentCommand = { + type: 'voteReward' as const, + requestId: 'vote-reward', + userId: 'user-7', + voteId: 1, + generalId: 7, + selection: [0], + }; const create = async () => { throw Object.assign(new Error('duplicate'), { code: 'P2002' }); }; @@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => { ); await expect( - transport.sendCommand({ - ...persistedPayload, - acceptedGameTick: 101, - }) + transport.sendCommand(currentCommand) ).resolves.toBe('vote-reward'); for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) { await expect( transport.sendCommand({ - ...persistedPayload, + ...currentCommand, ...changedIdentity, - acceptedGameTick: 101, }) ).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError); } diff --git a/app/game-api/test/inputEventBoundary.integration.test.ts b/app/game-api/test/inputEventBoundary.integration.test.ts index 011ae27c..ce3c1915 100644 --- a/app/game-api/test/inputEventBoundary.integration.test.ts +++ b/app/game-api/test/inputEventBoundary.integration.test.ts @@ -528,10 +528,6 @@ integration('API input event boundary', () => { it('reuses the same engine child event but rejects a changed retry payload', async () => { const transport = new DatabaseTurnDaemonTransport(db, 100); const requestId = 'integration:api:engine-child'; - const worldClock = await db.worldState.findFirst({ - orderBy: { id: 'asc' }, - select: { clockRevision: true, deadlineGeneration: true }, - }); const acceptedWindowStart = Date.now(); await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }); const acceptedWindowEnd = Date.now(); @@ -539,9 +535,10 @@ integration('API input event boundary', () => { expect(event.actorUserId).toBe('user-7'); expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart); expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd); - expect(event.acceptedGameTick).not.toBeNull(); - expect(event.acceptedClockRevision).toBe(worldClock?.clockRevision ?? null); - expect(event.acceptedDeadlineGeneration).toBe(worldClock?.deadlineGeneration ?? null); + expect(event.acceptedGameTick).toBeNull(); + expect(event.acceptedClockRevision).toBeNull(); + expect(event.acceptedDeadlineGeneration).toBeNull(); + expect(event.processingGameTick).toBeNull(); await expect( transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }) ).resolves.toBe(requestId); diff --git a/app/game-api/test/inputEventJournal.test.ts b/app/game-api/test/inputEventJournal.test.ts index 631db1fd..e3fb9faa 100644 --- a/app/game-api/test/inputEventJournal.test.ts +++ b/app/game-api/test/inputEventJournal.test.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import type { GameApiContext } from '../src/context.js'; import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js'; -import { procedure, router } from '../src/trpc.js'; +import { procedure, router, wallProcedure } from '../src/trpc.js'; const testRouter = router({ mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => { @@ -14,6 +14,14 @@ const testRouter = router({ }), }); +const wallTestRouter = router({ + mutate: wallProcedure.input(z.object({})).mutation(({ ctx }) => { + (ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler'); + ctx.changeJournal?.mark('front.general', 7); + return { ok: true }; + }), +}); + const createContext = (payload: unknown = {}) => { const order: string[] = []; const queryRaw = vi.fn(async (query: { sql?: string }) => { @@ -40,7 +48,18 @@ const createContext = (payload: unknown = {}) => { const transaction = { $queryRaw: queryRaw, $executeRaw: vi.fn(async (query: { sql?: string }) => { - order.push(query.sql?.includes('pg_advisory_xact_lock') ? 'clock-fence' : 'accepted'); + const sql = query.sql ?? ''; + order.push( + sql.includes('pg_advisory_xact_lock') + ? 'clock-fence' + : sql.includes("status = 'PROCESSING'") + ? 'processing' + : sql.includes("status = 'SUCCEEDED'") + ? 'succeeded' + : sql.includes("status = 'FAILED'") + ? 'failed' + : 'accepted' + ); return 1; }), $executeRawUnsafe: vi.fn(async (statement: string) => { @@ -131,4 +150,24 @@ describe('API input-event change journal boundary', () => { expect(fixture.redisPublish).not.toHaveBeenCalled(); expect(fixture.wake).not.toHaveBeenCalled(); }); + + it('keeps a WALL-only mutation durable without acquiring the GAME clock fence', async () => { + const fixture = createContext(); + + await expect(wallTestRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true }); + + expect(fixture.order).toEqual([ + 'transaction-begin', + 'accepted', + 'locked', + 'processing', + 'savepoint', + 'handler', + 'journal', + 'succeeded', + 'savepoint-release', + 'commit', + 'wake', + ]); + }); }); diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index e6f69462..f7305e6b 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -42,6 +42,7 @@ const buildContext = ( turnDaemonLease: { findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })), }, + $queryRaw: vi.fn(async () => [{ running: true }]), } as unknown as DatabaseClient, profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) }, }) as unknown as GameApiContext; diff --git a/app/game-api/test/messageTombstone.integration.test.ts b/app/game-api/test/messageTombstone.integration.test.ts index c3bf6e02..62576435 100644 --- a/app/game-api/test/messageTombstone.integration.test.ts +++ b/app/game-api/test/messageTombstone.integration.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; -import { tombstoneMessages } from '../src/messages/store.js'; +import { tombstoneMessages, tombstoneMessagesWithinDeleteWindow } from '../src/messages/store.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); @@ -70,6 +70,7 @@ integration('message deletion tombstone persistence', () => { expect(rows).toHaveLength(2); for (const row of rows) { expect(row.validUntil).toEqual(validUntil); + expect(row.tombstonedAtWall).not.toBeNull(); expect(row.message).toMatchObject({ text: '삭제된 메시지입니다.', option: { invalid: true }, @@ -81,4 +82,43 @@ integration('message deletion tombstone persistence', () => { }) ).rejects.toBe(rollback); }); + + it('uses the DB wall deadline even when the game clock is not advancing', async () => { + const rollback = new Error('rollback wall deletion fixture'); + await expect( + db.$transaction(async (transaction) => { + const [{ now_wall: nowWall }] = await transaction.$queryRaw>` + SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall + `; + const draft = (text: string) => ({ + mailbox: 7, + type: 'private' as const, + src: 7, + dest: 8, + time: new Date('0200-01-01T00:00:00.000Z'), + validUntil: new Date('9999-12-31T00:00:00.000Z'), + createdAtWall: nowWall, + message: { + src: { generalId: 7 }, + dest: { generalId: 8 }, + text, + option: {}, + }, + }); + const deletable = await transaction.message.create({ + data: { ...draft('future wall deadline'), deleteUntilWall: new Date(nowWall.getTime() + 60_000) }, + }); + const expired = await transaction.message.create({ + data: { ...draft('past wall deadline'), deleteUntilWall: new Date(nowWall.getTime() - 60_000) }, + }); + + expect( + await tombstoneMessagesWithinDeleteWindow(transaction, deletable.id, [deletable.id]) + ).toEqual([deletable.id]); + expect(await tombstoneMessagesWithinDeleteWindow(transaction, expired.id, [expired.id])).toEqual([]); + + throw rollback; + }) + ).rejects.toBe(rollback); + }); }); diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index c3a3e72f..c359ad47 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -210,7 +210,7 @@ describe('messages router missing-flow compatibility', () => { }); expect(result.msgType).toBe('national'); - expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); + expect(queryRaw).toHaveBeenCalledOnce(); }); it('journals committed message mailbox copies instead of publishing before commit', async () => { @@ -228,6 +228,84 @@ describe('messages router missing-flow compatibility', () => { expect(redis.publish).not.toHaveBeenCalled(); }); + it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)( + 'keeps ordinary public messages available while the game clock is %s', + async (clockPhase) => { + const queryRaw = vi.fn(async () => [{ id: 52 }]); + const { caller } = buildContext({ + $queryRaw: queryRaw, + worldState: { findFirst: vi.fn(async () => ({ clockPhase })) }, + }); + + await expect( + caller.messages.send({ generalId: general.id, mailbox: 9999, text: `${clockPhase} 공개 메시지` }) + ).resolves.toMatchObject({ msgType: 'public' }); + expect(queryRaw).toHaveBeenCalledOnce(); + } + ); + + it.each(['SUSPENDED', 'RECONCILING'] as const)( + 'keeps a received recruitment letter visible with its frozen game deadline while the clock is %s', + async (clockPhase) => { + const scoutRow = { + id: 54, + mailbox: general.id, + type: 'private', + src: 8, + dest: general.id, + time: new Date('0200-01-01T00:00:00.000Z'), + created_at_wall: new Date('2026-09-03T15:00:00.000Z'), + action_status: 'PENDING', + expires_game_tick: 200n, + message: { + src: { + generalId: 8, + generalName: '등용권유자', + nationId: 2, + nationName: '촉', + color: '#000', + icon: '', + }, + dest: { + generalId: general.id, + generalName: general.name, + nationId: general.nationId, + nationName: '위', + color: '#fff', + icon: '', + }, + text: '등용 권유 서신', + option: { action: 'scout' }, + }, + }; + const { caller } = buildContext({ + $queryRaw: vi.fn(async () => [scoutRow]), + worldState: { + findFirst: vi.fn(async () => ({ + clockBaseTime: new Date('0200-01-01T00:00:00.000Z'), + clockTick: 100n, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'), + tickSeconds: 600, + clockPhase, + clockRevision: 9n, + deadlineGeneration: 4n, + })), + }, + }); + + const result = await caller.messages.getRecent({ generalId: general.id }); + + expect(result.private[0]).toMatchObject({ + id: scoutRow.id, + text: '등용 권유 서신', + option: { action: 'scout' }, + time: '2026-09-03 15:00:00', + }); + expect(result.private[0]?.option).not.toMatchObject({ invalid: true }); + } + ); + it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => { const ambassador = { ...general, @@ -259,7 +337,7 @@ describe('messages router missing-flow compatibility', () => { }); expect(result.msgType).toBe('diplomacy'); - expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); + expect(queryRaw).toHaveBeenCalledTimes(2); }); it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => { @@ -288,7 +366,6 @@ describe('messages router missing-flow compatibility', () => { }); expect(result.msgType).toBe('diplomacy'); - expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy'])); expect(queryRaw).toHaveBeenCalledTimes(2); expect(changeJournal.snapshot()).toEqual([ { domain: 'messages.mailbox', entityId: 9000 }, @@ -314,7 +391,6 @@ describe('messages router missing-flow compatibility', () => { expect(result.msgType).toBe('national'); expect(queryRaw).toHaveBeenCalledTimes(1); - expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); }); it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => { @@ -555,44 +631,50 @@ describe('messages router missing-flow compatibility', () => { }); it('invalidates a recent owned message and its receiver copy', async () => { - const queryRaw = vi.fn(async () => [ - { - id: 21, - mailbox: general.id, - type: 'private', - src: general.id, - dest: 8, - time: new Date(), - valid_until: new Date('9999-12-31T00:00:00Z'), - message: { - src: { - generalId: general.id, - generalName: general.name, - nationId: 1, - nationName: '위', - color: '#fff', - icon: '', + let rawCall = 0; + const queryRaw = vi.fn(async () => { + rawCall += 1; + if (rawCall > 1) return [{ id: 21 }, { id: 22 }]; + return [ + { + id: 21, + mailbox: general.id, + type: 'private', + src: general.id, + dest: 8, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: general.id, + generalName: general.name, + nationId: 1, + nationName: '위', + color: '#fff', + icon: '', + }, + dest: { + generalId: 8, + generalName: '받는이', + nationId: 2, + nationName: '촉', + color: '#000', + icon: '', + }, + text: '삭제할 메시지', + option: { receiverMessageID: 22 }, }, - dest: { - generalId: 8, - generalName: '받는이', - nationId: 2, - nationName: '촉', - color: '#000', - icon: '', - }, - text: '삭제할 메시지', - option: { receiverMessageID: 22 }, }, - }, - ]); + ]; + }); const changeJournal = new ChangeJournal(); const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); const result = await caller.messages.delete({ generalId: general.id, messageId: 21 }); expect(result.deletedIds).toEqual([21, 22]); - expect(executeRaw).toHaveBeenCalledOnce(); + expect(queryRaw).toHaveBeenCalledTimes(2); + expect(executeRaw).not.toHaveBeenCalled(); expect(updateMany).not.toHaveBeenCalled(); expect(changeJournal.snapshot()).toEqual([ { domain: 'messages.mailbox', entityId: 7 }, @@ -601,43 +683,49 @@ describe('messages router missing-flow compatibility', () => { }); it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => { - const queryRaw = vi.fn(async () => [ - { - id: 25, - mailbox: 9001, - type: 'diplomacy', - src: 9001, - dest: 9002, - time: new Date(), - valid_until: new Date('9999-12-31T00:00:00Z'), - message: { - src: { - generalId: general.id, - generalName: general.name, - nationId: 1, - nationName: '위', - color: '#fff', - icon: '', + let rawCall = 0; + const queryRaw = vi.fn(async () => { + rawCall += 1; + if (rawCall > 1) return [{ id: 25 }]; + return [ + { + id: 25, + mailbox: 9001, + type: 'diplomacy', + src: 9001, + dest: 9002, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: general.id, + generalName: general.name, + nationId: 1, + nationName: '위', + color: '#fff', + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 2, + nationName: '촉', + color: '#000', + icon: '', + }, + text: '일반 외교 메시지', + option: { receiverMessageID: 26 }, }, - dest: { - generalId: 0, - generalName: '', - nationId: 2, - nationName: '촉', - color: '#000', - icon: '', - }, - text: '일반 외교 메시지', - option: { receiverMessageID: 26 }, }, - }, - ]); + ]; + }); const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }); const result = await caller.messages.delete({ generalId: general.id, messageId: 25 }); expect(result.deletedIds).toEqual([25]); - expect(executeRaw).toHaveBeenCalledOnce(); + expect(queryRaw).toHaveBeenCalledTimes(2); + expect(executeRaw).not.toHaveBeenCalled(); expect(updateMany).not.toHaveBeenCalled(); }); @@ -864,6 +952,7 @@ describe('messages router missing-flow compatibility', () => { const nationUpdate = vi.fn(async () => ({})); const logCreateMany = vi.fn(async () => ({ count: 1 })); const messageUpdateMany = vi.fn(async () => ({ count: 1 })); + const messageActionUpdateMany = vi.fn(async () => ({ count: 1 })); const cityUpdate = vi.fn(async () => ({})); const changeJournal = new ChangeJournal(); const { caller } = buildContext( @@ -941,10 +1030,19 @@ describe('messages router missing-flow compatibility', () => { currentYear: 200, currentMonth: 3, config: { environment: { mapName: 'che' } }, + clockBaseTime: new Date('0200-03-01T00:00:00.000Z'), + clockTick: 1_000n, + clockMode: 'manual', + clockWallAnchor: new Date('2026-09-03T00:00:00.000Z'), + tickSeconds: 600, + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, })), }, logEntry: { createMany: logCreateMany }, message: { updateMany: messageUpdateMany }, + messageAction: { updateMany: messageActionUpdateMany }, $queryRaw: queryRaw, }, { changeJournal } @@ -987,7 +1085,7 @@ describe('messages router missing-flow compatibility', () => { ); expect(setup.messageUpdateMany).toHaveBeenCalledWith({ where: { id: { in: [31] } }, - data: { validUntil: expect.any(Date), validUntilTick: 0n }, + data: { validUntil: expect.any(Date), validUntilTick: 1_000n }, }); expect(setup.queryRaw).toHaveBeenCalledTimes(9); }); diff --git a/app/game-api/test/nationBettingRouter.integration.test.ts b/app/game-api/test/nationBettingRouter.integration.test.ts index 8acbd732..8b1f3f36 100644 --- a/app/game-api/test/nationBettingRouter.integration.test.ts +++ b/app/game-api/test/nationBettingRouter.integration.test.ts @@ -13,13 +13,16 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const bettingId = 990_071; const concurrentBettingId = 990_072; +const phaseBettingId = 990_073; const generalId = 9_971; const otherGeneralId = 9_972; +const phaseGeneralId = 9_973; const nationId = 990_071; const otherNationId = 990_072; const userId = 'nation-betting-router-user'; const otherUserId = 'nation-betting-router-other-user'; const noGeneralUserId = 'nation-betting-router-no-general-user'; +const phaseUserId = 'nation-betting-router-phase-user'; const auth: GameSessionTokenPayload = { version: 1, @@ -58,6 +61,17 @@ const noGeneralAuth: GameSessionTokenPayload = { }, }; +const phaseAuth: GameSessionTokenPayload = { + ...auth, + sessionId: 'nation-betting-router-phase-session', + user: { + ...auth.user, + id: phaseUserId, + username: 'phase-bettor', + displayName: 'Phase Bettor', + }, +}; + integration('nation betting router', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; @@ -90,12 +104,14 @@ integration('nation betting router', () => { await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); - await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); - await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); - await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); - await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); - await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); - await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); + await db.inputEvent.deleteMany({ + where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } }, + }); + await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } }); + await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } }); await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); await db.nation.createMany({ @@ -138,6 +154,17 @@ integration('nation betting router', () => { turnTime: new Date('0200-01-01T00:00:00.000Z'), meta: {}, }, + { + id: phaseGeneralId, + userId: phaseUserId, + name: '정지중베팅장수', + nationId, + cityId: 1, + npcState: 0, + officerLevel: 0, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: {}, + }, ], }); const world = await db.worldState.create({ @@ -169,6 +196,17 @@ integration('nation betting router', () => { ], }, }); + await db.nationBetting.create({ + data: { + id: phaseBettingId, + name: '정지 중 베팅', + selectCount: 1, + requiresInheritancePoint: true, + openYearMonth: 2_400, + closeYearMonth: 2_424, + candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }], + }, + }); await db.nationBetting.create({ data: { id: concurrentBettingId, @@ -184,17 +222,20 @@ integration('nation betting router', () => { data: [ { userId, key: 'previous', value: 1_000 }, { userId: otherUserId, key: 'previous', value: 500 }, + { userId: phaseUserId, key: 'previous', value: 500 }, ], }); }); afterAll(async () => { - await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); - await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); - await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); - await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); - await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); - await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); + await db.inputEvent.deleteMany({ + where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } }, + }); + await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } }); + await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } }); + await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } }); await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); await db.worldState.delete({ where: { id: worldStateId } }); await closeDb?.(); @@ -290,6 +331,51 @@ integration('nation betting router', () => { ).toMatchObject({ value: 250 }); }); + it('accepts nation betting during suspension but rejects it during reconciliation', async () => { + const before = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }); + const frozenTick = before.clockTick; + await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'SUSPENDED' } }); + + await expect( + appRouter.createCaller(buildContext('nation-betting-suspended', phaseAuth)).betting.bet({ + bettingId: phaseBettingId, + bettingType: [0], + amount: 100, + }) + ).resolves.toEqual({ result: true }); + await expect( + db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: phaseUserId, key: 'previous' } }, + }) + ).resolves.toMatchObject({ value: 400 }); + await expect( + db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } }) + ).resolves.toMatchObject({ amount: 100 }); + await expect(db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).resolves.toMatchObject({ + clockPhase: 'SUSPENDED', + clockTick: frozenTick, + }); + + await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RECONCILING' } }); + await expect( + appRouter.createCaller(buildContext('nation-betting-reconciling', phaseAuth)).betting.bet({ + bettingId: phaseBettingId, + bettingType: [0], + amount: 50, + }) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + await expect( + db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: phaseUserId, key: 'previous' } }, + }) + ).resolves.toMatchObject({ value: 400 }); + await expect( + db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } }) + ).resolves.toMatchObject({ amount: 100 }); + + await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RUNNING' } }); + }); + it('requires authentication and an owned player general for every betting operation', async () => { await expect( appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({ diff --git a/app/game-api/test/npcPossession.integration.test.ts b/app/game-api/test/npcPossession.integration.test.ts index 9eeaf8c3..2d740919 100644 --- a/app/game-api/test/npcPossession.integration.test.ts +++ b/app/game-api/test/npcPossession.integration.test.ts @@ -420,7 +420,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae }); }, 45_000); - it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => { + it('revalidates a queued token at the authoritative daemon processing tick', async () => { const reservation = await appRouter .createCaller(buildContext('npc-possession-delayed-token', delayedAuth)) .join.listPossessCandidates({}); @@ -440,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae ).rejects.toMatchObject({ code: 'TIMEOUT' }); const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); - const acceptedGameAt = new Date( - (event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time' - ); - expect(acceptedGameAt.toString()).not.toBe('Invalid Date'); + expect(event.acceptedGameTick).toBeNull(); + expect(event.processingGameTick).toBeNull(); + expect(event.payload).not.toHaveProperty('acceptedGameAt'); + const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!; await db.npcSelectionToken.update({ where: { ownerUserId: delayedUserId }, - data: { validUntil: acceptedGameAt }, + data: { validUntilTick: queuedAtTick }, }); await db.worldState.updateMany({ data: { clockTick: { increment: 1 } }, @@ -470,12 +470,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae await startRuntime('npc-possession-delayed-retry-daemon'); await expect( appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input) - ).resolves.toEqual({ ok: true, generalId: candidate.id }); + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '유효한 장수 목록이 없습니다.' }); await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ status: 'SUCCEEDED', attempts: 1, + processingGameTick: expect.anything(), }); - expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(1); + expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(0); }, 45_000); it('serializes durable enqueue before a token refresh can replace its nonce', async () => { diff --git a/app/game-api/test/readModelOutboxWorker.test.ts b/app/game-api/test/readModelOutboxWorker.test.ts index e41b2412..bac68a3f 100644 --- a/app/game-api/test/readModelOutboxWorker.test.ts +++ b/app/game-api/test/readModelOutboxWorker.test.ts @@ -27,15 +27,16 @@ const payload = ( const createFixture = (rows: readonly object[]) => { const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]); - const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const executeRaw = vi.fn().mockResolvedValue(1); const incr = vi.fn().mockResolvedValue(41); const publish = vi.fn().mockResolvedValue(1); const db = { $queryRaw: queryRaw, - readModelOutbox: { updateMany }, + $executeRaw: executeRaw, + readModelOutbox: {}, } as unknown as ReadModelOutboxDatabase; const redis = { incr, publish } as unknown as RedisConnector['client']; - return { db, redis, queryRaw, updateMany, incr, publish }; + return { db, redis, queryRaw, executeRaw, incr, publish }; }; describe('ReadModelOutboxWorker', () => { @@ -47,7 +48,7 @@ describe('ReadModelOutboxWorker', () => { }); worker.start(); - await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1)); await worker.stop(); expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({ @@ -64,7 +65,7 @@ describe('ReadModelOutboxWorker', () => { }); worker.start(); - await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1)); await worker.stop(); expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision'); @@ -74,9 +75,7 @@ describe('ReadModelOutboxWorker', () => { revision: 41, changes: { frontStatusActorIds: [7] }, }); - expect(fixture.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } }) - ); + expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"'); }); it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)( @@ -89,7 +88,7 @@ describe('ReadModelOutboxWorker', () => { }); worker.start(); - await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1)); await worker.stop(); expect(fixture.incr).not.toHaveBeenCalled(); @@ -105,7 +104,7 @@ describe('ReadModelOutboxWorker', () => { }); worker.start(); - await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1)); await worker.stop(); expect(fixture.incr).not.toHaveBeenCalled(); @@ -150,22 +149,15 @@ describe('ReadModelOutboxWorker', () => { }); worker.start(); - await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1)); await worker.stop(); expect(onError).toHaveBeenCalledWith( expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' }) ); - expect(fixture.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null }, - data: expect.objectContaining({ - lockedAt: null, - lockOwner: null, - lastError: expect.stringContaining('redis unavailable'), - }), - }) - ); + const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] }; + expect(releaseQuery.sql).toContain('"available_at"'); + expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable')); }); it('prunes only a bounded retention batch on the lower-frequency cadence', async () => { diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index f2425ade..ce457cfe 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -710,7 +710,7 @@ describe('appRouter', () => { ).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); - it('queues selection-pool reservation with the authenticated actor and server logical time', async () => { + it('queues selection-pool reservation without pre-assigning an API game coordinate', async () => { const transport = new InMemoryTurnDaemonTransport(); const requestId = 'select-pool-reserve-http'; const commandRequestId = `select-pool:user-1:${requestId}:reserve`; @@ -741,8 +741,6 @@ describe('appRouter', () => { requestId: commandRequestId, userId: 'user-1', seedOwnerIdentity: 'user-1', - acceptedGameAt, - acceptedGameTick: 0, }); }); diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 43c3a50f..192d5b7c 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -227,19 +227,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => { .listGeneralPoolCandidates(new Date(firstReservation.validUntil)) ?.some((candidate) => reservedNames.has(candidate.uniqueName)) ).toBe(false); - await expect( - db.inputEvent.findUniqueOrThrow({ - where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` }, - }) - ).resolves.toMatchObject({ + const reserveEvent = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` }, + }); + expect(reserveEvent).toMatchObject({ eventType: 'selectPoolReserve', status: 'SUCCEEDED', actorUserId: userId, - payload: { - acceptedGameAt: expect.any(String), - acceptedGameTick: expect.any(Number), - }, + processingGameTick: expect.anything(), }); + expect(reserveEvent.payload).not.toHaveProperty('acceptedGameAt'); + expect(reserveEvent.payload).not.toHaveProperty('acceptedGameTick'); const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const; const attempts = await Promise.allSettled([ @@ -357,6 +355,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { ).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' }); const cooledAt = '2026-07-29T00:00:00.000Z'; + const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1; await expect( turnDaemon.requestCommand({ type: 'patchGeneral', @@ -366,6 +365,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { meta: { next_change: cooledAt, nextChangeAt: cooledAt, + next_change_tick: cooledTick, }, }, }) @@ -380,16 +380,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => { .createCaller(buildContext('select-pool-reselect')) .join.reselectPoolGeneral({ uniqueName: target.uniqueName }) ).resolves.toEqual({ ok: true, generalId: initial.id }); - await expect( - db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } }) - ).resolves.toMatchObject({ + const reselectionEvent = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' }, + }); + expect(reselectionEvent).toMatchObject({ eventType: 'selectPoolReselect', actorUserId: userId, - payload: { - acceptedGameAt: expect.any(String), - acceptedGameTick: expect.any(Number), - }, + processingGameTick: expect.anything(), }); + expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt'); + expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick'); const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } }); expect(updated).toMatchObject({ @@ -455,6 +455,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue }, }); const secondCooledAt = '2026-07-28T00:00:00.000Z'; + const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1; await turnDaemon.requestCommand({ type: 'patchGeneral', requestId: 'select-pool-full-cooldown-patch', @@ -463,6 +464,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { meta: { next_change: secondCooledAt, nextChangeAt: secondCooledAt, + next_change_tick: secondCooledTick, }, }, }); @@ -571,21 +573,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => { .join.selectPoolGeneral(stableInput); expect(retried).toEqual(first); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1); - await expect( - db.inputEvent.findUniqueOrThrow({ - where: { - requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`, - }, - }) - ).resolves.toMatchObject({ + const stableEvent = await db.inputEvent.findUniqueOrThrow({ + where: { + requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`, + }, + }); + expect(stableEvent).toMatchObject({ status: 'SUCCEEDED', attempts: 1, actorUserId: otherUserId, - payload: { - acceptedGameAt: expect.any(String), - acceptedGameTick: expect.any(Number), - }, + processingGameTick: expect.anything(), }); + expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt'); + expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick'); }, 30_000); it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => { diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index 6947cb6e..bd1845fa 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; @@ -151,6 +151,8 @@ const buildContext = (options: { develCost?: number; currentDevelCost?: number; rankRows?: Array<{ generalId: number; type: string; value: number }>; + clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING'; + requestId?: string; }): GameApiContext => { const db = { general: { @@ -168,7 +170,7 @@ const buildContext = (options: { clockTick: 0n, clockMode: 'realtime', clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'), - clockPhase: 'RUNNING', + clockPhase: options.clockPhase ?? 'RUNNING', clockRevision: 1n, deadlineGeneration: 1n, tickSeconds: 60, @@ -178,6 +180,7 @@ const buildContext = (options: { }, } as unknown as DatabaseClient; return { + requestId: options.requestId, db, redis: options.redis as unknown as RedisConnector['client'], turnDaemon: options.transport, @@ -369,6 +372,83 @@ describe('tournament router permissions and mutations', () => { expect(transport.gold.get(general.id)).toBe(2_400); }); + it('accepts a tournament bet against the frozen game deadline while the clock is suspended', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const general = buildGeneral(1, 'user-1', 3_000); + transport.gold.set(general.id, general.gold); + await setTournamentFixture(redis, { + stage: 6, + phase: 0, + type: 0, + auto: true, + openYear: 193, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-07-26T01:00:00.000Z', + bettingCloseAt: '2099-01-01T00:00:00.000Z', + }); + const context = buildContext({ + redis, + transport, + generals: [general], + userId: 'user-1', + clockPhase: 'SUSPENDED', + requestId: 'http:suspended-tournament-bet', + }); + const outerApiTransaction = vi.fn(async () => { + throw new Error('tournament bet must not hold an API transaction while waiting for the daemon'); + }); + Object.assign(context.db, { $transaction: outerApiTransaction }); + const caller = appRouter.createCaller(context); + + await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).resolves.toEqual({ ok: true }); + expect(transport.gold.get(general.id)).toBe(2_400); + expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600); + expect(transport.commands).toContainEqual( + expect.objectContaining({ + type: 'adjustGeneralResources', + requestId: 'http:suspended-tournament-bet:tournamentBet:resources', + reason: 'tournamentBet', + }) + ); + expect(outerApiTransaction).not.toHaveBeenCalled(); + expect(transport.commands).toContainEqual( + expect.objectContaining({ + type: 'adjustGeneralMeta', + requestId: 'http:suspended-tournament-bet:tournamentBet:rank', + reason: 'tournamentBet', + }) + ); + }); + + it('rejects a tournament bet during reconciliation without debiting gold', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const general = buildGeneral(1, 'user-1', 3_000); + transport.gold.set(general.id, general.gold); + await setTournamentFixture(redis, { + stage: 6, + phase: 0, + type: 0, + auto: true, + openYear: 193, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-07-26T01:00:00.000Z', + bettingCloseAt: '2099-01-01T00:00:00.000Z', + }); + const caller = appRouter.createCaller( + buildContext({ redis, transport, generals: [general], userId: 'user-1', clockPhase: 'RECONCILING' }) + ); + + await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + expect(transport.gold.get(general.id)).toBe(3_000); + expect(transport.commands).toHaveLength(0); + }); + it('keeps another user from reading my bet identity and requires that user to own a general', async () => { const redis = new MemoryRedis(); const transport = new TournamentTransport(); diff --git a/app/game-api/test/turnEngineStatus.test.ts b/app/game-api/test/turnEngineStatus.test.ts index 8d6e1e26..b825c450 100644 --- a/app/game-api/test/turnEngineStatus.test.ts +++ b/app/game-api/test/turnEngineStatus.test.ts @@ -5,10 +5,8 @@ import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/t describe('turn engine status projection', () => { it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => { const activeLease = { - turnDaemonLease: { - findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })), - }, - }; + $queryRaw: vi.fn(async () => [{ running: true }]), + } as any; const now = new Date('2026-08-24T00:00:00.000Z'); await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe( true @@ -36,7 +34,7 @@ describe('turn engine status projection', () => { await expect( loadTurnEngineRunning( source, - { turnDaemonLease: { findUnique: async () => null } }, + { $queryRaw: async () => [{ running: false }] } as any, 'che:default', now ) @@ -44,11 +42,7 @@ describe('turn engine status projection', () => { await expect( loadTurnEngineRunning( source, - { - turnDaemonLease: { - findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }), - }, - }, + { $queryRaw: async () => [{ running: false }] } as any, 'che:default', now ) @@ -58,10 +52,10 @@ describe('turn engine status projection', () => { it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => { let now = 1_000; const get = vi.fn(async () => 'RUNNING' as const); - const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })); + const queryRaw = vi.fn(async () => [{ running: true }]); const cache = new CachedTurnEngineStatus( { get }, - { turnDaemonLease: { findUnique } }, + { $queryRaw: queryRaw } as any, 'che:default', 2_000, () => now @@ -75,6 +69,6 @@ describe('turn engine status projection', () => { now += 1; await expect(cache.get()).resolves.toBe(true); expect(get).toHaveBeenCalledTimes(2); - expect(findUnique).toHaveBeenCalledTimes(2); + expect(queryRaw).toHaveBeenCalledTimes(2); }); }); diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index f958a6a3..dd20a394 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -230,13 +230,7 @@ describe('vote router actor and permission boundaries', () => { expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false); expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true); - expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false); - expect( - hasPollEnded( - { closed_at: null, end_at: now, end_tick: null }, - { ...time, now: new Date(now.getTime() + 1), tick: null } - ) - ).toBe(true); + expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(true); }); it('rejects unauthenticated survey access', async () => { @@ -261,7 +255,6 @@ describe('vote router actor and permission boundaries', () => { voteId: 1, generalId: 7, selection: [0], - acceptedGameTick: 100, }); expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe( false @@ -301,8 +294,6 @@ describe('vote router actor and permission boundaries', () => { const auth = buildAuth(['admin.survey.open']); const fixture = buildContext({ auth }); const caller = appRouter.createCaller(fixture.context); - const windowStart = Date.now(); - await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true }); await expect( caller.vote.createPoll({ @@ -314,8 +305,6 @@ describe('vote router actor and permission boundaries', () => { ).resolves.toEqual({ ok: true }); await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true }); await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true }); - const windowEnd = Date.now(); - const mutationQueries = fixture.queryRaw.mock.calls .map(([query]) => query) .filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query))); @@ -325,30 +314,24 @@ describe('vote router actor and permission boundaries', () => { const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL')); const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE')); const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id')); - const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => { + const expectDbWallClock = (query: GamePrisma.Sql | undefined): void => { expect(query).toBeDefined(); - const value = query?.values.at(index); - expect(value).toBeInstanceOf(Date); - expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart); - expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd); - return value as Date; + expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"); }; expect(sqlText(commentInsert!)).toContain('created_at'); - expectCurrentDateAt(commentInsert, -1); + expectDbWallClock(commentInsert); expect(sqlText(pollInsert!)).toContain('created_at'); expect(sqlText(pollInsert!)).toContain('updated_at'); - const pollCreatedAt = expectCurrentDateAt(pollInsert, -2); - const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1); - expect(pollUpdatedAt).toBe(pollCreatedAt); + expectDbWallClock(pollInsert); expect(pollUpdates).toHaveLength(3); expect(sqlText(closePreviousUpdate!)).toContain('updated_at'); - expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt); + expectDbWallClock(closePreviousUpdate); expect(sqlText(editPollUpdate!)).toContain('updated_at'); - expectCurrentDateAt(editPollUpdate, -2); + expectDbWallClock(editPollUpdate); expect(sqlText(closePollUpdate!)).toContain('updated_at'); - expectCurrentDateAt(closePollUpdate, -2); + expectDbWallClock(closePollUpdate); }); it('reports the current world develcost as the legacy five-times survey reward', async () => { diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index 7877b431..b0c9a5a2 100644 --- a/app/game-engine/src/auction/bidder.ts +++ b/app/game-engine/src/auction/bidder.ts @@ -60,27 +60,27 @@ export const hasAuctionClosePassed = ( auction: { closeAt: Date; closeTick: bigint | null }, now: Date, nowTick: number | null -): boolean => - auction.closeTick !== null && nowTick !== null - ? auction.closeTick < BigInt(nowTick) - : auction.closeAt.getTime() < now.getTime(); +): boolean => { + void now; + return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick); +}; export const resolveAuctionBidTiming = ( - world: Pick, - processingNow: Date, - acceptedGameTick?: number -): { bidAt: Date; bidTick: number } => - acceptedGameTick === undefined - ? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) } - : { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick }; + world: Pick, + processingGameTick: number +): { bidAt: Date; bidTick: number } => { + if (!Number.isSafeInteger(processingGameTick)) { + throw new Error('Auction bid requires an authoritative daemon processing game tick.'); + } + return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick }; +}; export const hasAuctionBidClosePassed = ( auction: { closeAt: Date; closeTick: bigint | null }, - world: Pick, - processingNow: Date, - acceptedGameTick?: number + world: Pick, + processingGameTick: number ): boolean => { - const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick); + const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick); return hasAuctionClosePassed(auction, bidAt, bidTick); }; @@ -268,15 +268,15 @@ export const createAuctionBidder = async (options: { reason: '경매가 종료되었습니다.', }; } - const processingNow = world.getGameNow(new Date()); const convertedProcessingTick = Reflect.get(command, 'processingGameTick'); - const { bidAt, bidTick } = resolveAuctionBidTiming( - world, - processingNow, - typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick) - ? convertedProcessingTick - : command.acceptedGameTick - ); + if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) { + throw new Error('auctionBid requires an authoritative daemon processing game tick.'); + } + const requestedAtWall = Reflect.get(command, 'requestedAtWall'); + if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) { + throw new Error('auctionBid requires its durable input-event wall occurrence.'); + } + const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick); if (hasAuctionClosePassed(auction, bidAt, bidTick)) { return { type: 'auctionBid', @@ -510,13 +510,24 @@ export const createAuctionBidder = async (options: { const persistBid = async (tx: GamePrisma.TransactionClient): Promise => { await tx.$executeRaw( GamePrisma.sql` - INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta) + INSERT INTO auction_bid ( + auction_id, + general_id, + amount, + event_id, + event_at, + occurred_game_tick, + requested_at_wall, + meta + ) VALUES ( ${command.auctionId}, ${command.generalId}, ${command.amount}, ${eventId}, ${eventAt}, + ${BigInt(bidTick)}, + ${requestedAtWall}, ${JSON.stringify({ tryExtendCloseDate: command.tryExtendCloseDate ?? true, ...(auction.type === 'UNIQUE_ITEM' @@ -536,7 +547,7 @@ export const createAuctionBidder = async (options: { close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, latest_event_id = ${eventId}, latest_event_at = ${eventAt}, - updated_at = ${eventAt} + updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE id = ${command.auctionId} AND status = 'OPEN' AND latest_event_id = ${auction.latestEventId} @@ -556,7 +567,7 @@ export const createAuctionBidder = async (options: { GamePrisma.sql` UPDATE inheritance_point SET value = value - ${morePoint}, - updated_at = ${eventAt} + updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE user_id = ${userId} AND key = 'previous' AND value >= ${morePoint} @@ -587,11 +598,16 @@ export const createAuctionBidder = async (options: { await tx.$executeRaw( GamePrisma.sql` INSERT INTO inheritance_point (user_id, key, value, updated_at) - VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt}) + VALUES ( + ${prevUserId}, + 'previous', + ${highestBid.amount}, + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + ) ON CONFLICT (user_id, key) DO UPDATE SET value = inheritance_point.value + EXCLUDED.value, - updated_at = EXCLUDED.updated_at + updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ` ); await tx.$executeRaw( @@ -711,6 +727,7 @@ export const createAuctionBidder = async (options: { ok: true, auctionId: command.auctionId, closeAt: nextCloseAt.toISOString(), + closeTick: world.dateToGameTick(nextCloseAt), }; }, close: async (): Promise => { diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts index 14c18e00..4700dd7b 100644 --- a/app/game-engine/src/auction/finalizer.ts +++ b/app/game-engine/src/auction/finalizer.ts @@ -91,21 +91,17 @@ export const isAuctionFinalizeGenerationCurrent = ( auction: Pick, command: Pick, 'expectedCloseAt' | 'expectedCloseTick'> ): boolean => { - if (command.expectedCloseTick !== undefined) { - return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick); - } - if (command.expectedCloseAt !== undefined) { - return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime(); - } - return true; + return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick); }; export const hasAuctionFinalizeDeadlineArrived = ( auction: Pick, now: Date, nowTick: number -): boolean => - auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick); +): boolean => { + void now; + return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick); +}; export const buildAuctionBidderSystemMessage = (options: { bidder: TurnGeneral; @@ -293,7 +289,11 @@ export const createAuctionFinalizer = async (options: { return { type: 'auctionFinalize', ok: true, auctionId }; } - const now = world.getGameNow(new Date()); + const processingGameTick = Reflect.get(command, 'processingGameTick'); + if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) { + throw new Error('auctionFinalize requires an authoritative daemon processing game tick.'); + } + const now = world.gameTickToDate(processingGameTick); if (auction.status === 'OPEN') { if (!isAuctionFinalizeGenerationCurrent(auction, command)) { return { @@ -303,8 +303,7 @@ export const createAuctionFinalizer = async (options: { reason: '경매 마감 세대가 변경되었습니다.', }; } - const nowTick = world.dateToGameTick(now); - if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) { + if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) { return { type: 'auctionFinalize', ok: false, @@ -316,8 +315,8 @@ export const createAuctionFinalizer = async (options: { GamePrisma.sql` UPDATE auction SET status = 'FINALIZING', - finalizing_at = ${now}, - updated_at = ${now} + finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE id = ${auctionId} AND status = 'OPEN' ` @@ -364,8 +363,8 @@ export const createAuctionFinalizer = async (options: { GamePrisma.sql` UPDATE auction SET status = ${status}, - finished_at = ${now}, - updated_at = ${now} + finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE id = ${auctionId} ` ); diff --git a/app/game-engine/src/auction/opener.ts b/app/game-engine/src/auction/opener.ts index 69117710..f3972362 100644 --- a/app/game-engine/src/auction/opener.ts +++ b/app/game-engine/src/auction/opener.ts @@ -94,7 +94,11 @@ const openResourceAuction = async ( return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`); } - const now = world.getGameNow(new Date()); + const processingGameTick = Reflect.get(command, 'processingGameTick'); + if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) { + throw new Error('auctionOpen requires an authoritative daemon processing game tick.'); + } + const now = world.gameTickToDate(processingGameTick); const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60)); const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000); const auction = await db.auction.create({ @@ -113,7 +117,7 @@ const openResourceAuction = async ( }, status: 'OPEN', closeAt, - openTick: BigInt(world.dateToGameTick(now)), + openTick: BigInt(processingGameTick), closeTick: BigInt(world.dateToGameTick(closeAt)), }, }); @@ -125,6 +129,7 @@ const openResourceAuction = async ( ok: true, auctionId: auction.id, closeAt: closeAt.toISOString(), + closeTick: world.dateToGameTick(closeAt), }; }; @@ -220,8 +225,16 @@ const openUniqueAuction = async ( } const state = world.getState(); + const processingGameTick = Reflect.get(command, 'processingGameTick'); + if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) { + throw new Error('auctionOpen requires an authoritative daemon processing game tick.'); + } + const requestedAtWall = Reflect.get(command, 'requestedAtWall'); + if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) { + throw new Error('auctionOpen requires its durable input-event wall occurrence.'); + } const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60)); - const now = world.getGameNow(new Date()); + const now = world.gameTickToDate(processingGameTick); const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES); const closeAt = new Date(now.getTime() + closeMinutes * 60_000); const extensionLimitMinutes = Math.max( @@ -253,7 +266,7 @@ const openUniqueAuction = async ( }, status: 'OPEN', closeAt, - openTick: BigInt(world.dateToGameTick(now)), + openTick: BigInt(processingGameTick), closeTick: BigInt(world.dateToGameTick(closeAt)), latestEventId: eventId, latestEventAt: now, @@ -263,6 +276,8 @@ const openUniqueAuction = async ( amount: command.amount, eventId, eventAt: now, + occurredGameTick: BigInt(processingGameTick), + requestedAtWall, meta: buildInitialUniqueAuctionBidMeta(alias, command.amount), }, }, @@ -308,6 +323,7 @@ const openUniqueAuction = async ( ok: true, auctionId: auction.id, closeAt: closeAt.toISOString(), + closeTick: world.dateToGameTick(closeAt), }; }; diff --git a/app/game-engine/src/lifecycle/databaseCommandQueue.ts b/app/game-engine/src/lifecycle/databaseCommandQueue.ts index 98b57656..03391aef 100644 --- a/app/game-engine/src/lifecycle/databaseCommandQueue.ts +++ b/app/game-engine/src/lifecycle/databaseCommandQueue.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import { performance } from 'node:perf_hooks'; +import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra'; import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js'; import type { @@ -10,8 +11,9 @@ import type { TurnDaemonStatus, } from './types.js'; -const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +const serializeResult = (value: unknown): string => + JSON.stringify(value, (_key, item: unknown) => (typeof item === 'bigint' ? item.toString() : item)) ?? 'null'; export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder { private readonly localQueue: TurnDaemonCommand[] = []; @@ -35,8 +37,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T return local.concat(remote); } - async waitUntil(deadlineMs: number | null): Promise { - while (deadlineMs === null || Date.now() < deadlineMs) { + async waitFor(timeoutMs: number | null): Promise { + const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs); + while (deadline === null || performance.now() < deadline) { const local = this.localQueue.shift(); if (local) { return local; @@ -45,7 +48,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T if (remote[0]) { return remote[0]; } - const remaining = deadlineMs === null ? 100 : Math.max(1, Math.min(100, deadlineMs - Date.now())); + const remaining = deadline === null ? 100 : Math.max(1, Math.min(100, deadline - performance.now())); await delay(remaining); } return null; @@ -84,35 +87,37 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T return; } const terminal = event.attempts >= this.maxAttempts; - await transaction.inputEvent.updateMany({ - where: { - requestId, - target: 'ENGINE', - status: 'PROCESSING', - lockedBy: this.workerId, - attempts: event.attempts, - }, - data: { - status: terminal ? 'FAILED' : 'PENDING', - processingAt: null, - lockedBy: null, - leaseUntil: null, - completedAt: terminal ? new Date() : null, - result: GamePrisma.DbNull, - error: message, - }, - }); + await transaction.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus", + processing_at = NULL, + locked_by = NULL, + lease_until = NULL, + completed_at = CASE + WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + ELSE NULL + END, + result = NULL, + error = ${message} + WHERE request_id = ${requestId} + AND target = 'ENGINE'::"InputEventTarget" + AND status = 'PROCESSING'::"InputEventStatus" + AND locked_by = ${this.workerId} + AND attempts = ${event.attempts} + `); }); } private async claimPending(limit = 100): Promise { await this.recoverExpiredLeases(); return this.db.$transaction(async (transaction) => { + const claimCoordinate = await readInputEventClockCoordinate(transaction); const world = await transaction.worldState.findFirst({ orderBy: { id: 'asc' }, select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true }, }); const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL'; + const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED'; const currentRevision = world?.clockRevision ?? null; const rows = await transaction.$queryRaw< Array<{ @@ -141,6 +146,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T AND ( ${gameplayAllowed} OR "event_type" = 'getStatus' + OR ( + ${suspendedTournamentBetCommand} + AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta') + AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback') + ) OR ( ${world?.clockPhase === 'SUSPENDED'} AND "event_type" = 'messageRespond' @@ -148,8 +158,14 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T AND EXISTS ( SELECT 1 FROM "message" AS pending_message + JOIN "message_action" AS pending_action + ON pending_action."message_id" = pending_message."id" WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer AND pending_message."message" #>> '{option,action}' = 'raiseInvader' + AND pending_action."action_type" = 'raiseInvader' + AND pending_action."status" = 'PENDING' + AND pending_action."clock_revision" = ${currentRevision} + AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null} ) AND EXISTS ( SELECT 1 @@ -175,9 +191,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T }) : []; const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => { - if (row.eventType === 'getStatus') return row.acceptedGameTick; + if (row.eventType === 'getStatus') return row.acceptedGameTick ?? claimCoordinate.gameTick; if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) { - return row.acceptedGameTick ?? world?.clockTick ?? null; + return claimCoordinate.gameTick; } if (row.acceptedClockRevision > currentRevision) return undefined; let revision = row.acceptedClockRevision; @@ -197,19 +213,25 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T entry.processingGameTick !== undefined ); for (const { row, processingGameTick } of processableRows) { - await transaction.inputEvent.update({ - where: { sequence: row.sequence }, - data: { - status: 'PROCESSING', - processingAt: new Date(), - processingGameTick, - processingClockRevision: currentRevision, - processingDeadlineGeneration: world?.deadlineGeneration ?? null, - lockedBy: this.workerId, - leaseUntil: new Date(Date.now() + this.leaseDurationMs), - attempts: { increment: 1 }, - }, - }); + await transaction.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET status = 'PROCESSING'::"InputEventStatus", + processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + accepted_game_tick = COALESCE(accepted_game_tick, ${processingGameTick}), + accepted_clock_revision = COALESCE(accepted_clock_revision, ${currentRevision}), + accepted_deadline_generation = COALESCE( + accepted_deadline_generation, + ${world?.deadlineGeneration ?? null} + ), + processing_game_tick = ${processingGameTick}, + processing_clock_revision = ${currentRevision}, + processing_deadline_generation = ${world?.deadlineGeneration ?? null}, + locked_by = ${this.workerId}, + lease_until = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + + ${this.leaseDurationMs} * INTERVAL '1 millisecond', + attempts = attempts + 1 + WHERE sequence = ${row.sequence} + `); } const commands: TurnDaemonCommand[] = []; @@ -220,39 +242,34 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T command: row.payload as TurnDaemonCommand, }); if (!command) { - await transaction.inputEvent.update({ - where: { sequence: row.sequence }, - data: { - status: 'FAILED', - error: `Invalid command payload for ${row.eventType}`, - completedAt: new Date(), - lockedBy: null, - leaseUntil: null, - }, - }); + await transaction.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET status = 'FAILED'::"InputEventStatus", + error = ${`Invalid command payload for ${row.eventType}`}, + completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + locked_by = NULL, + lease_until = NULL + WHERE sequence = ${row.sequence} + `); continue; } - if ( - processingGameTick !== null && - row.acceptedGameTick !== null && - processingGameTick !== row.acceptedGameTick - ) { + if (processingGameTick !== null) { const value = Number(processingGameTick); if (!Number.isSafeInteger(value)) { - await transaction.inputEvent.update({ - where: { sequence: row.sequence }, - data: { - status: 'FAILED', - error: 'Converted processing game tick is outside the safe integer range.', - completedAt: new Date(), - lockedBy: null, - leaseUntil: null, - }, - }); + await transaction.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET status = 'FAILED'::"InputEventStatus", + error = 'Converted processing game tick is outside the safe integer range.', + completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + locked_by = NULL, + lease_until = NULL + WHERE sequence = ${row.sequence} + `); continue; } Reflect.set(command, 'processingGameTick', value); } + Reflect.set(command, 'requestedAtWall', row.createdAt); commands.push(command); } return commands; @@ -260,23 +277,20 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T } private async complete(requestId: string, result: unknown): Promise { - const completed = await this.db.inputEvent.updateMany({ - where: { - requestId, - target: 'ENGINE', - status: 'PROCESSING', - lockedBy: this.workerId, - }, - data: { - status: 'SUCCEEDED', - result: asJson(result), - completedAt: new Date(), - error: null, - lockedBy: null, - leaseUntil: null, - }, - }); - if (completed.count > 0) { + const completed = await this.db.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET status = 'SUCCEEDED'::"InputEventStatus", + result = CAST(${serializeResult(result)} AS jsonb), + completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + error = NULL, + locked_by = NULL, + lease_until = NULL + WHERE request_id = ${requestId} + AND target = 'ENGINE'::"InputEventTarget" + AND status = 'PROCESSING'::"InputEventStatus" + AND locked_by = ${this.workerId} + `); + if (completed > 0) { return; } // Database hooks commit mutation results atomically with game state and @@ -297,19 +311,15 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T } private async recoverExpiredLeases(): Promise { - const now = new Date(); - await this.db.inputEvent.updateMany({ - where: { - target: 'ENGINE', - status: 'PROCESSING', - leaseUntil: { lt: now }, - }, - data: { - status: 'PENDING', - processingAt: null, - lockedBy: null, - leaseUntil: null, - }, - }); + await this.db.$executeRaw(GamePrisma.sql` + UPDATE input_event + SET status = 'PENDING'::"InputEventStatus", + processing_at = NULL, + locked_by = NULL, + lease_until = NULL + WHERE target = 'ENGINE'::"InputEventTarget" + AND status = 'PROCESSING'::"InputEventStatus" + AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + `); } } diff --git a/app/game-engine/src/lifecycle/inMemoryControlQueue.ts b/app/game-engine/src/lifecycle/inMemoryControlQueue.ts index 67478ee8..f2074537 100644 --- a/app/game-engine/src/lifecycle/inMemoryControlQueue.ts +++ b/app/game-engine/src/lifecycle/inMemoryControlQueue.ts @@ -1,7 +1,7 @@ import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js'; type Waiter = { - deadlineMs: number | null; + timeoutMs: number | null; resolve: (command: TurnDaemonCommand | null) => void; timeoutId?: ReturnType; }; @@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue { return drained; } - async waitUntil(deadlineMs: number | null): Promise { + async waitFor(timeoutMs: number | null): Promise { if (this.queue.length > 0) { return this.queue.shift() ?? null; } return new Promise((resolve) => { - const waiter: Waiter = { deadlineMs, resolve }; - if (deadlineMs !== null) { - const delay = Math.max(0, deadlineMs - Date.now()); + const waiter: Waiter = { timeoutMs, resolve }; + if (timeoutMs !== null) { + const delay = Math.max(0, timeoutMs); waiter.timeoutId = setTimeout(() => { this.removeWaiter(waiter); resolve(null); diff --git a/app/game-engine/src/lifecycle/redisCommandStream.ts b/app/game-engine/src/lifecycle/redisCommandStream.ts index fec7d460..13a0be56 100644 --- a/app/game-engine/src/lifecycle/redisCommandStream.ts +++ b/app/game-engine/src/lifecycle/redisCommandStream.ts @@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur return drained.concat(remote); } - async waitUntil(deadlineMs: number | null): Promise { + async waitFor(timeoutMs: number | null): Promise { if (this.localQueue.length > 0) { return this.localQueue.shift() ?? null; } - const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now()); - const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000); - if (deadlineMs !== null && blockMs === 0) { + const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs); + const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000); + if (timeoutMs !== null && blockMs === 0) { return null; } diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 99f902de..bc8f2a18 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -222,7 +222,7 @@ export class TurnDaemonLifecycle { continue; } - const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs)); + const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs)); if (command) { await this.handleCommand(command); } @@ -268,7 +268,7 @@ export class TurnDaemonLifecycle { } private async waitForResume(): Promise { - const command = await this.controlQueue.waitUntil(null); + const command = await this.controlQueue.waitFor(null); if (command) { await this.handleCommand(command); } diff --git a/app/game-engine/src/lifecycle/types.ts b/app/game-engine/src/lifecycle/types.ts index 6e720284..ca27dbb0 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -82,7 +82,7 @@ export interface TurnStateStore { export interface TurnDaemonControlQueue { enqueue(command: TurnDaemonCommand): void; drain(): Promise; - waitUntil(deadlineMs: number | null): Promise; + waitFor(timeoutMs: number | null): Promise; getDepth(): number; } diff --git a/app/game-engine/src/turn/actionableMessageResponse.ts b/app/game-engine/src/turn/actionableMessageResponse.ts index 09a4bae1..50ade5fb 100644 --- a/app/game-engine/src/turn/actionableMessageResponse.ts +++ b/app/game-engine/src/turn/actionableMessageResponse.ts @@ -18,6 +18,10 @@ interface MessageRow { type: string; time: Date; validUntil: Date; + actionType: string; + actionStatus: string; + createdGameTick: bigint; + expiresGameTick: bigint | null; message: unknown; } @@ -98,11 +102,16 @@ const invalidateMessageIds = async ( ): Promise => { const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))]; if (uniqueIds.length === 0) return; + const resolvedGameTick = BigInt(world.dateToGameTick(now)); + await db.messageAction.updateMany({ + where: { messageId: { in: uniqueIds }, status: 'PENDING' }, + data: { status: 'RESOLVED', resolvedGameTick }, + }); await db.message.updateMany({ where: { id: { in: uniqueIds } }, data: { validUntil: now, - validUntilTick: BigInt(world.dateToGameTick(now)), + validUntilTick: resolvedGameTick, }, }); }; @@ -113,40 +122,53 @@ const validateActor = async (options: { requestId?: string; userId: string; generalId: number; -}): Promise => { +}): Promise<{ processingGameTick: number }> => { const actor = options.world.getGeneralById(options.generalId); if (!actor || actor.userId !== options.userId) { throw new Error('messageRespond general owner does not match command user.'); } - if (!options.requestId) return new Date(); + if (!options.requestId) { + throw new Error('messageRespond requires a durable ENGINE input event requestId.'); + } const event = await options.db.inputEvent.findUnique({ where: { requestId: options.requestId }, - select: { actorUserId: true, target: true, eventType: true, createdAt: true }, + select: { actorUserId: true, target: true, eventType: true, processingGameTick: true }, }); if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`); if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') { throw new Error('ENGINE input event actor or type does not match messageRespond.'); } - return event.createdAt; + const processingGameTick = event.processingGameTick; + if (processingGameTick === null || !Number.isSafeInteger(Number(processingGameTick))) { + throw new Error('messageRespond requires an authoritative processing game tick.'); + } + return { processingGameTick: Number(processingGameTick) }; }; const fetchMessageForUpdate = async ( db: GamePrisma.TransactionClient, - world: InMemoryTurnWorld, messageId: number, - now: Date + currentGameTick: number ): Promise => { - const currentTick = BigInt(world.dateToGameTick(now)); const rows = await db.$queryRaw(GamePrisma.sql` - SELECT id, mailbox, type, time, valid_until AS "validUntil", message - FROM message - WHERE id = ${messageId} - AND ( - (valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick}) - OR (valid_until_tick IS NULL AND valid_until > ${now}) - ) + SELECT + envelope.id, + envelope.mailbox, + envelope.type, + envelope.time, + envelope.valid_until AS "validUntil", + action.action_type AS "actionType", + action.status AS "actionStatus", + action.created_game_tick AS "createdGameTick", + action.expires_game_tick AS "expiresGameTick", + envelope.message + FROM message AS envelope + JOIN message_action AS action ON action.message_id = envelope.id + WHERE envelope.id = ${messageId} + AND action.status = 'PENDING' + AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(currentGameTick)}) LIMIT 1 - FOR UPDATE + FOR UPDATE OF envelope, action `); return rows[0] ?? null; }; @@ -165,7 +187,7 @@ const respondToScout = async (options: { if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) { return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' }; } - if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) { + if (row.actionStatus !== 'PENDING' || row.actionType !== 'scout' || isLegacyTruthy(asRecord(payload.option).used)) { return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' }; } @@ -186,18 +208,17 @@ const respondToScout = async (options: { } const otherRows = await db.$queryRaw>(GamePrisma.sql` - SELECT id - FROM message - WHERE mailbox = ${payload.src.generalId} - AND type = 'private' - AND dest = mailbox - AND id <> ${row.id} - AND ( - (valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))}) - OR (valid_until_tick IS NULL AND valid_until > ${now}) - ) - AND message->'option'->>'action' = 'scout' - FOR UPDATE + SELECT envelope.id + FROM message AS envelope + JOIN message_action AS action ON action.message_id = envelope.id + WHERE envelope.mailbox = ${payload.src.generalId} + AND envelope.type = 'private' + AND envelope.dest = envelope.mailbox + AND envelope.id <> ${row.id} + AND action.status = 'PENDING' + AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))}) + AND action.action_type = 'scout' + FOR UPDATE OF envelope, action `); await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now); world.queueMessage({ @@ -327,6 +348,7 @@ const respondToRaiseInvader = async (options: { }, event ); + await invalidateMessageIds(db, world, [row.id], world.gameTickToDate(alignment.alignedTick)); return { ok: true, action: 'raiseInvader', reason: 'success' }; }; @@ -354,13 +376,14 @@ export const respondToActionableMessage = async (options: { authority: NonNullable[0]['authority']>; }) => Promise; }): Promise => { - const acceptedAt = await validateActor(options); - const now = options.world.getGameNow(acceptedAt); - const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now); + const accepted = await validateActor(options); + const now = options.world.gameTickToDate(accepted.processingGameTick); + const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick); if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' }; const payload = parsePayload(row.message); if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' }; const action = asRecord(payload.option).action; + if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' }; if (action === 'scout') { return await respondToScout({ ...options, actorId: options.generalId, row, payload, now }); } diff --git a/app/game-engine/src/turn/clockReconciliation.ts b/app/game-engine/src/turn/clockReconciliation.ts index fc1dde66..607ef1a6 100644 --- a/app/game-engine/src/turn/clockReconciliation.ts +++ b/app/game-engine/src/turn/clockReconciliation.ts @@ -147,7 +147,7 @@ const lockWorld = async (db: GamePrisma.TransactionClient): Promise => { return rows[0]!.id; }; -const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigint): Promise => { +const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigint): Promise => { await db.$queryRaw(GamePrisma.sql`SELECT id FROM general ORDER BY id FOR UPDATE`); await db.$queryRaw(GamePrisma.sql` SELECT id FROM auction @@ -155,10 +155,18 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigin ORDER BY id FOR UPDATE `); await db.$queryRaw(GamePrisma.sql` - SELECT id FROM message - WHERE valid_until_tick IS NOT NULL AND valid_until_tick >= ${cutTick} - ORDER BY id FOR UPDATE + SELECT bid.id + FROM auction_bid AS bid + JOIN auction ON auction.id = bid.auction_id + WHERE auction.status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status) + ORDER BY bid.id FOR UPDATE OF bid `); + await db.$queryRaw(GamePrisma.sql` + SELECT message_id AS id FROM message_action + WHERE status = 'PENDING' + ORDER BY message_id FOR UPDATE + `); + await db.$queryRaw(GamePrisma.sql`SELECT id FROM inheritance_ledger ORDER BY id FOR UPDATE`); await db.$queryRaw(GamePrisma.sql` SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE `); @@ -175,7 +183,8 @@ const readParticipantSnapshots = async ( worldStateId: number, cutTick: bigint ): Promise => { - const [world, generals, auctions, messages, votes, pool, npcTokens, commands] = await Promise.all([ + const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] = + await Promise.all([ db.worldState.findUniqueOrThrow({ where: { id: worldStateId }, select: { @@ -188,17 +197,32 @@ const readParticipantSnapshots = async ( }), db.general.findMany({ orderBy: { id: 'asc' }, - select: { id: true, turnTick: true, recentWarTick: true }, + select: { id: true, turnTick: true, recentWarTick: true, meta: true }, }), db.auction.findMany({ where: { status: { in: ['OPEN', 'FINALIZING'] } }, orderBy: { id: 'asc' }, select: { id: true, status: true, openTick: true, closeTick: true }, }), - db.message.findMany({ - where: { validUntilTick: { not: null, gte: cutTick } }, + db.auctionBid.findMany({ + where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } }, orderBy: { id: 'asc' }, - select: { id: true, timeTick: true, validUntilTick: true }, + select: { id: true, occurredGameTick: true }, + }), + db.messageAction.findMany({ + where: { status: 'PENDING' }, + orderBy: { messageId: 'asc' }, + select: { + messageId: true, + createdGameTick: true, + expiresGameTick: true, + clockRevision: true, + deadlineGeneration: true, + }, + }), + db.inheritanceLedger.findMany({ + orderBy: { id: 'asc' }, + select: { id: true, appliedClockRevision: true, appliedDeadlineGeneration: true }, }), db.votePoll.findMany({ where: { closedAt: null }, @@ -219,7 +243,7 @@ const readParticipantSnapshots = async ( orderBy: { sequence: 'asc' }, select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true }, }), - ]); + ]); const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({ key, policy, @@ -246,6 +270,18 @@ const readParticipantSnapshots = async ( 'KEEP', generals.map(({ id, recentWarTick }) => ({ id, recentWarTick })) ), + snapshot( + 'selection-reselection-deadline', + 'SHIFT', + generals.flatMap(({ id, meta: generalMeta }) => { + const raw = + generalMeta && typeof generalMeta === 'object' && !Array.isArray(generalMeta) + ? Reflect.get(generalMeta, 'next_change_tick') + : null; + const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN; + return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : []; + }) + ), snapshot( 'auction-open-occurrence', 'KEEP', @@ -256,21 +292,34 @@ const readParticipantSnapshots = async ( 'SHIFT', auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick })) ), + snapshot('auction-bid-occurrence', 'KEEP', auctionBids), snapshot( 'auction-finalizing-recovery', 'REBUILD', auctions.map(({ id, status }) => ({ id, status })) ), snapshot( - 'message-occurrence', + 'message-action-occurrence', 'KEEP', - messages.map(({ id, timeTick }) => ({ id, timeTick })) + messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick })) ), snapshot( - 'message-expiry', + 'message-action-expiry', 'SHIFT', - messages.map(({ id, validUntilTick }) => ({ id, validUntilTick })) + messages + .filter(({ expiresGameTick }) => expiresGameTick !== null && expiresGameTick >= cutTick) + .map(({ messageId, expiresGameTick }) => ({ messageId, expiresGameTick })) ), + snapshot( + 'message-action-clock-coordinate', + 'REBUILD', + messages.map(({ messageId, clockRevision, deadlineGeneration }) => ({ + messageId, + clockRevision, + deadlineGeneration, + })) + ), + snapshot('inheritance-effect-coordinate', 'KEEP', inheritanceEffects), snapshot( 'vote-start-occurrence', 'KEEP', @@ -283,7 +332,7 @@ const readParticipantSnapshots = async ( ), snapshot('select-pool-reservation', 'SHIFT', pool), snapshot('npc-selection-window', 'SHIFT', npcTokens), - snapshot('accepted-command-coordinate', 'KEEP', commands), + snapshot('daemon-command-coordinate', 'KEEP', commands), snapshot('movable-json-rule-anchors', 'SHIFT', [ { lastTurnTime: Reflect.get(meta, 'lastTurnTime'), @@ -429,15 +478,20 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise => { const shift = BigInt(shiftTicks); const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift; - const [general, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([ + const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([ db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }), + db.$queryRaw>(GamePrisma.sql` + SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick" + FROM general + WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$' + `), db.auction.aggregate({ _max: { closeTick: true }, where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } }, }), - db.message.aggregate({ - _max: { validUntilTick: true }, - where: { validUntilTick: { not: null, lt: BigInt(MAX_SAFE_GAME_TICK) } }, + db.messageAction.aggregate({ + _max: { expiresGameTick: true }, + where: { status: 'PENDING', expiresGameTick: { not: null } }, }), db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }), db.selectPoolEntry.aggregate({ @@ -452,8 +506,9 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks ]); const values: Array<[string, bigint | null]> = [ ['general.turn_tick', general._max.turnTick], + ['general.meta.next_change_tick', reselection[0]?.maxTick ?? null], ['auction.close_tick', auction._max.closeTick], - ['message.valid_until_tick', message._max.validUntilTick], + ['message_action.expires_game_tick', message._max.expiresGameTick], ['vote_poll.end_tick', vote._max.endTick], ['select_pool.reserved_until_tick', pool._max.reservedUntilTick], ['select_npc_token.valid_until_tick', npcValid._max.validUntilTick], @@ -493,6 +548,32 @@ const applyParticipantShift = async ( WHERE turn_tick IS NOT NULL `) ); + affected.set( + 'selection-reselection-deadline', + await db.$executeRaw(GamePrisma.sql` + UPDATE general + SET meta = jsonb_set( + jsonb_set( + jsonb_set( + meta, + '{next_change_tick}', + to_jsonb((meta->>'next_change_tick')::bigint + ${shiftTicks}), + true + ), + '{next_change}', + to_jsonb(((meta->>'next_change')::timestamp + + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text), + true + ), + '{nextChangeAt}', + to_jsonb(((meta->>'nextChangeAt')::timestamp + + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text), + true + ) + WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$' + AND (meta->>'next_change_tick')::bigint >= ${cutTick} + `) + ); affected.set( 'auction-deadline', await db.$executeRaw(GamePrisma.sql` @@ -504,14 +585,37 @@ const applyParticipantShift = async ( `) ); affected.set( - 'message-expiry', + 'message-action-clock-coordinate', + ( + await db.messageAction.updateMany({ + where: { status: 'PENDING' }, + data: { + clockRevision: targetRevision, + deadlineGeneration: targetGeneration, + }, + }) + ).count + ); + affected.set( + 'message-action-expiry', await db.$executeRaw(GamePrisma.sql` - UPDATE message - SET valid_until_tick = valid_until_tick + ${shiftTicks}, - valid_until = valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' - WHERE valid_until_tick IS NOT NULL - AND valid_until_tick >= ${cutTick} - AND valid_until_tick < ${BigInt(MAX_SAFE_GAME_TICK)} + WITH shifted AS ( + UPDATE message_action + SET expires_game_tick = expires_game_tick + ${shiftTicks}, + clock_revision = ${targetRevision}, + deadline_generation = ${targetGeneration}, + updated_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + WHERE status = 'PENDING' + AND expires_game_tick IS NOT NULL + AND expires_game_tick >= ${cutTick} + RETURNING message_id, expires_game_tick + ) + UPDATE message AS envelope + SET valid_until_tick = shifted.expires_game_tick, + valid_until = envelope.valid_until + + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' + FROM shifted + WHERE envelope.id = shifted.message_id `) ); affected.set( diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 2b70fc96..fb6ff3e9 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({ type: z.literal('auctionFinalize'), auctionId: zFiniteNumber, expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(), - expectedCloseTick: zSafeInteger.optional(), + expectedCloseTick: zSafeInteger, }); const zAuctionOpen = z.object({ @@ -60,7 +60,6 @@ const zAuctionBid = z.object({ auctionId: zFiniteNumber, generalId: zFiniteNumber, amount: zFiniteNumber, - acceptedGameTick: zSafeInteger.optional(), tryExtendCloseDate: z.boolean().optional(), }); @@ -427,7 +426,7 @@ const zSelectPoolReserve = z requestId: z.string().optional(), userId: z.string().min(1), seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]), - acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp), + acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(), acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(), }) .strict(); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index e72bd746..7fa31c39 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -7,6 +7,7 @@ import { writeReadModelChangeJournal, enqueuePrivateMessageWebPush, enqueueWebPushOutboxEvents, + persistMessageEnvelope, type InputJsonValue, type ReadModelJournalWriteResult, type TurnEngineCityUpdateInput, @@ -1862,37 +1863,18 @@ export const createDatabaseTurnHooks = async ( await sendMessage( { insertMessage: async (draft: MessageRecordDraft) => { - const toTickOrNull = (date: Date): bigint | null => { - try { - return BigInt(world.dateToGameTick(date)); - } catch { - // Legacy messages may use year 9999 as an - // effectively-unbounded expiry, beyond the - // safe JavaScript tick range. - return null; - } - }; - const rows = await prisma.$queryRaw>` - INSERT INTO message ( - mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message - ) - VALUES ( - ${draft.mailbox}, - ${draft.msgType}, - ${draft.srcId}, - ${draft.destId}, - ${draft.time}, - ${toTickOrNull(draft.time)}, - ${draft.validUntil}, - ${toTickOrNull(draft.validUntil)}, - CAST(${JSON.stringify(draft.payload)} AS jsonb) - ) - RETURNING id - `; - const id = rows[0]?.id; - if (!id) { - throw new Error('Failed to persist turn message.'); - } + const clock = world.getGameClockState(); + const action = draft.payload.option && Reflect.get(draft.payload.option, 'action'); + const expiresGameTick = + typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000 + ? null + : BigInt(world.dateToGameTick(draft.validUntil)); + const id = await persistMessageEnvelope(prisma, draft, { + occurredGameTick: BigInt(world.dateToGameTick(draft.time)), + clockRevision: BigInt(clock.revision), + deadlineGeneration: BigInt(clock.deadlineGeneration), + expiresGameTick, + }); await enqueuePrivateMessageWebPush(prisma, draft, id); persistedMessageMailboxes.push(draft.mailbox); return id; diff --git a/app/game-engine/src/turn/gatewayAdminActions.ts b/app/game-engine/src/turn/gatewayAdminActions.ts index 2607ab6a..67e17dfd 100644 --- a/app/game-engine/src/turn/gatewayAdminActions.ts +++ b/app/game-engine/src/turn/gatewayAdminActions.ts @@ -1,4 +1,4 @@ -import { createGatewayPostgresConnector } from '@sammo-ts/infra'; +import { createGatewayPostgresConnector, GatewayPrisma } from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED'; @@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async ( continue; } const terminal = result.status !== 'PARTIAL'; - const updated = await prisma.gatewayRuntimeAction.updateMany({ - where: { - id: action.id, - status: { in: ['REQUESTED', 'PARTIAL'] }, - }, - data: { - status: result.status, - detail: result.detail ?? null, - handler: 'turn-daemon', - handledAt: terminal ? new Date() : null, - attempts: { increment: 1 }, - nextAttemptAt: terminal - ? null - : new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))), - }, - }); - if (terminal && updated.count > 0) { + const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6)); + const updated = await prisma.$queryRaw>(GatewayPrisma.sql` + UPDATE gateway_runtime_action + SET status = ${result.status}::"GatewayRuntimeActionStatus", + detail = ${result.detail ?? null}, + handler = 'turn-daemon', + handled_at = CASE + WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + ELSE NULL + END, + attempts = attempts + 1, + next_attempt_at = CASE + WHEN ${terminal} THEN NULL + ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + + ${retryDelayMs} * INTERVAL '1 millisecond' + END, + updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + WHERE id = ${action.id} + AND status IN ('REQUESTED'::"GatewayRuntimeActionStatus", 'PARTIAL'::"GatewayRuntimeActionStatus") + RETURNING id + `); + if (terminal && updated.length > 0) { await options.onActionApplied?.(actionRecord, result); } } diff --git a/app/game-engine/src/turn/gatewayProfileGate.ts b/app/game-engine/src/turn/gatewayProfileGate.ts index ea6cb7f6..473424ba 100644 --- a/app/game-engine/src/turn/gatewayProfileGate.ts +++ b/app/game-engine/src/turn/gatewayProfileGate.ts @@ -1,3 +1,5 @@ +import { performance } from 'node:perf_hooks'; + import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common'; import { createGatewayPostgresConnector } from '@sammo-ts/infra'; @@ -42,7 +44,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption return { // 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다. async shouldPause(): Promise { - const now = Date.now(); + const now = performance.now(); if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) { return cachedPause; } diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index fb8382fa..83c6182c 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -1,3 +1,5 @@ +import { performance } from 'node:perf_hooks'; + import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import type { InMemoryTurnWorld, TurnCalendarContext } from './inMemoryWorld.js'; @@ -50,16 +52,16 @@ export class InMemoryTurnProcessor implements TurnProcessor { } async run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise { - const startMs = Date.now(); + const startMs = performance.now(); const deadlineMs = startMs + Math.max(0, budget.budgetMs); - const isBudgetExpired = () => Date.now() >= deadlineMs; + const isBudgetExpired = () => performance.now() >= deadlineMs; if (isWorldUnited(this.world)) { return { lastTurnTime: this.world.getState().lastTurnTime.toISOString(), processedGenerals: 0, processedTurns: 0, - durationMs: Math.max(0, Date.now() - startMs), + durationMs: Math.max(0, performance.now() - startMs), partial: false, checkpoint, }; @@ -171,7 +173,7 @@ export class InMemoryTurnProcessor implements TurnProcessor { lastTurnTime, processedGenerals, processedTurns, - durationMs: Math.max(0, Date.now() - startMs), + durationMs: Math.max(0, performance.now() - startMs), partial, checkpoint: nextCheckpoint, }; diff --git a/app/game-engine/src/turn/inheritanceActionService.ts b/app/game-engine/src/turn/inheritanceActionService.ts index 6e6e9535..a3b89a19 100644 --- a/app/game-engine/src/turn/inheritanceActionService.ts +++ b/app/game-engine/src/turn/inheritanceActionService.ts @@ -310,7 +310,7 @@ export const resolveOwnerDisplayName = (rawMeta: unknown): string => { return '알수없음'; }; -export const executeInheritanceAction = async (options: { +const executeInheritanceActionMutation = async (options: { db: GamePrisma.TransactionClient; world: InMemoryTurnWorld; command: InheritanceActionCommand; @@ -668,3 +668,59 @@ export const executeInheritanceAction = async (options: { }); return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost }; }; + +/** + * Persists the WALL_TIME inheritance receipt in the same transaction as the + * point debit, game mutation, and input-event completion. The input_event row + * is the durable retry/failure record and owns the authoritative GAME clock + * coordinate; an immediate effect does not invent a separate applied tick. + */ +export const executeInheritanceAction = async (options: { + db: GamePrisma.TransactionClient; + world: InMemoryTurnWorld; + command: InheritanceActionCommand; + gameNow: Date; +}): Promise => { + const result = await executeInheritanceActionMutation(options); + if (!result.ok || !options.command.requestId) return result; + + const event = await options.db.inputEvent.findUnique({ + where: { requestId: options.command.requestId }, + select: { + actorUserId: true, + target: true, + eventType: true, + createdAt: true, + processingClockRevision: true, + processingDeadlineGeneration: true, + }, + }); + if ( + !event || + event.actorUserId !== options.command.userId || + event.target !== 'ENGINE' || + event.eventType !== 'inheritanceAction' || + event.processingClockRevision === null || + event.processingDeadlineGeneration === null + ) { + throw new Error('Inheritance ledger requires the authoritative ENGINE input-event clock fence.'); + } + const previousPoint = await lockPreviousPoint(options.db, options.command.userId); + const cost = previousPoint - result.remainPoint; + if (!Number.isFinite(cost) || cost < 0) { + throw new Error(`Inheritance ledger calculated an invalid cost: ${cost}.`); + } + await options.db.inheritanceLedger.create({ + data: { + requestId: options.command.requestId, + userId: options.command.userId, + action: result.action, + cost, + status: 'APPLIED', + requestedAtWall: event.createdAt, + appliedClockRevision: event.processingClockRevision, + appliedDeadlineGeneration: event.processingDeadlineGeneration, + }, + }); + return result; +}; diff --git a/app/game-engine/src/turn/npcPossessionService.ts b/app/game-engine/src/turn/npcPossessionService.ts index 8af9b0f4..bd9b428d 100644 --- a/app/game-engine/src/turn/npcPossessionService.ts +++ b/app/game-engine/src/turn/npcPossessionService.ts @@ -1,6 +1,6 @@ import { randomInt } from 'node:crypto'; -import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; +import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; import { acquireGameSchemaAdvisoryXactLock, GamePrisma, @@ -84,7 +84,9 @@ export interface NpcPossessionSelectionObserver { interface NpcSelectionTokenRow { ownerUserId: string; validUntil: Date; + validUntilTick: bigint | null; pickMoreFrom: Date; + pickMoreFromTick: bigint | null; pickResult: unknown; nonce: number; } @@ -105,8 +107,8 @@ const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTi export const buildNpcSelectionTokenSeed = ( hiddenSeed: string | number, ownerIdentity: string | number, - acceptedGameTick: number -): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick); + createdGameTick: number +): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, createdGameTick); const readHiddenSeed = (worldState: WorldStateRow): string | number => { const meta = asRecord(worldState.meta); @@ -159,15 +161,22 @@ const parsePickResult = (value: unknown): Record }; const toReservation = ( - token: Pick, - now: Date + token: Pick< + NpcSelectionTokenRow, + 'validUntil' | 'validUntilTick' | 'pickMoreFrom' | 'pickMoreFromTick' | 'pickResult' | 'nonce' + >, + currentGameTick: number, + ticksPerSecond: number ): NpcPossessionReservation => { + if (token.validUntilTick === null || token.pickMoreFromTick === null) { + return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보의 GAME_TIME 기한이 없습니다.'); + } const pickResult = parsePickResult(token.pickResult); return { tokenNonce: token.nonce, validUntil: token.validUntil.toISOString(), pickMoreFrom: token.pickMoreFrom.toISOString(), - pickMoreSeconds: Math.max(0, Math.ceil((token.pickMoreFrom.getTime() - now.getTime()) / 1000)), + pickMoreSeconds: Math.max(0, Math.ceil((Number(token.pickMoreFromTick) - currentGameTick) / ticksPerSecond)), candidates: Object.values(pickResult).sort( (left, right) => left.stats.leadership + @@ -289,16 +298,18 @@ export const reserveNpcPossessionCandidates = async (options: { refresh?: boolean; keepIds?: number[]; now?: Date; - acceptedGameTick: number; + createdGameTick: number; selectionObserver?: NpcPossessionSelectionObserver; }): Promise => { const { db, worldState, userId } = options; requireNpcPossessionWorld(worldState); const now = truncateToSeconds(options.now ?? new Date()); - if (!Number.isSafeInteger(options.acceptedGameTick)) { - fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.'); + if (!Number.isSafeInteger(options.createdGameTick)) { + fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 생성 tick이 올바르지 않습니다.'); } await lockNpcPossession(db, userId); + const turnTermMinutes = resolveTurnTermMinutes(worldState); + const ticksPerSecond = GAME_TICKS_PER_TURN / (turnTermMinutes * 60); if (await db.general.findFirst({ where: { userId }, select: { id: true } })) { fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다'); @@ -324,14 +335,20 @@ export const reserveNpcPossessionCandidates = async (options: { if (options.refresh) { fail('CONFLICT', 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.'); } - return toReservation(inFlightToken, now); + return toReservation(inFlightToken, options.createdGameTick, ticksPerSecond); } - if (existing && existing.validUntil.getTime() < now.getTime()) { + if ( + existing && + (existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick) + ) { await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId, nonce: existing.nonce, - validUntil: { lt: now }, + OR: [ + { validUntilTick: null }, + { validUntilTick: { lt: BigInt(options.createdGameTick) } }, + ], }, }); existing = null; @@ -339,7 +356,7 @@ export const reserveNpcPossessionCandidates = async (options: { const kept: Record = {}; if (existing && options.refresh) { - if (now.getTime() < existing.pickMoreFrom.getTime()) { + if (existing.pickMoreFromTick === null || options.createdGameTick < Number(existing.pickMoreFromTick)) { fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다'); } const oldPick = parsePickResult(existing.pickResult); @@ -352,16 +369,16 @@ export const reserveNpcPossessionCandidates = async (options: { } // Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다. if (Object.keys(kept).length === Object.keys(oldPick).length) { - return toReservation(existing, now); + return toReservation(existing, options.createdGameTick, ticksPerSecond); } } else if (existing) { - return toReservation(existing, now); + return toReservation(existing, options.createdGameTick, ticksPerSecond); } const reservedRows = await db.npcSelectionToken.findMany({ where: { ownerUserId: { not: userId }, - validUntil: { gte: now }, + validUntilTick: { gte: BigInt(options.createdGameTick) }, }, select: { pickResult: true }, }); @@ -397,16 +414,19 @@ export const reserveNpcPossessionCandidates = async (options: { generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId))) ); const selectionRng = new LiteHashDRBG( - buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick) + buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.createdGameTick) ); const rng = options.selectionObserver?.onRandomDraw ? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw) : new RandUtil(selectionRng); const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw); - const turnTermMinutes = resolveTurnTermMinutes(worldState); - const validUntil = new Date(now.getTime() + Math.max(VALID_SECONDS, turnTermMinutes * 40) * 1000); + const validSeconds = Math.max(VALID_SECONDS, turnTermMinutes * 40); + const pickMoreSeconds = Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8)); + const validUntilTick = options.createdGameTick + Math.round(validSeconds * ticksPerSecond); + const pickMoreFromTick = options.createdGameTick + Math.round(pickMoreSeconds * ticksPerSecond); + const validUntil = new Date(now.getTime() + validSeconds * 1000); const refreshedPickMoreFrom = new Date( - now.getTime() + Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8)) * 1000 + now.getTime() + pickMoreSeconds * 1000 ); const nonce = randomInt(0, 0x10000000); @@ -415,7 +435,9 @@ export const reserveNpcPossessionCandidates = async (options: { where: { ownerUserId: userId, nonce: existing.nonce }, data: { validUntil, + validUntilTick: BigInt(validUntilTick), pickMoreFrom: refreshedPickMoreFrom, + pickMoreFromTick: BigInt(pickMoreFromTick), pickResult: pickResult as GamePrisma.InputJsonValue, nonce, }, @@ -423,7 +445,18 @@ export const reserveNpcPossessionCandidates = async (options: { if (updated.count === 0) { fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요'); } - return toReservation({ validUntil, pickMoreFrom: refreshedPickMoreFrom, pickResult, nonce }, now); + return toReservation( + { + validUntil, + validUntilTick: BigInt(validUntilTick), + pickMoreFrom: refreshedPickMoreFrom, + pickMoreFromTick: BigInt(pickMoreFromTick), + pickResult, + nonce, + }, + options.createdGameTick, + ticksPerSecond + ); } try { @@ -431,7 +464,9 @@ export const reserveNpcPossessionCandidates = async (options: { data: { ownerUserId: userId, validUntil, + validUntilTick: BigInt(validUntilTick), pickMoreFrom: FIRST_PICK_MORE_FROM, + pickMoreFromTick: BigInt(options.createdGameTick), pickResult: pickResult as GamePrisma.InputJsonValue, nonce, }, @@ -442,7 +477,18 @@ export const reserveNpcPossessionCandidates = async (options: { } throw error; } - return toReservation({ validUntil, pickMoreFrom: FIRST_PICK_MORE_FROM, pickResult, nonce }, now); + return toReservation( + { + validUntil, + validUntilTick: BigInt(validUntilTick), + pickMoreFrom: FIRST_PICK_MORE_FROM, + pickMoreFromTick: BigInt(options.createdGameTick), + pickResult, + nonce, + }, + options.createdGameTick, + ticksPerSecond + ); }; export const possessNpcGeneral = async (options: { @@ -455,11 +501,13 @@ export const possessNpcGeneral = async (options: { ownerLegacyPenalty?: Record; generalId: number; tokenNonce: number; - acceptedAt: Date; + requestedAtWall: Date; + processingGameTick: number; }): Promise<{ ok: true; generalId: number }> => { - const { db, world, worldState, userId, generalId, acceptedAt } = options; - // queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다. - const tokenAcceptedAt = truncateToSeconds(acceptedAt); + const { db, world, worldState, userId, generalId, requestedAtWall } = options; + if (!Number.isSafeInteger(options.processingGameTick)) { + fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 처리 tick이 올바르지 않습니다.'); + } requireNpcPossessionWorld(worldState); await lockNpcPossession(db, userId); await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`); @@ -475,7 +523,7 @@ export const possessNpcGeneral = async (options: { where: { ownerUserId: userId, nonce: options.tokenNonce, - validUntil: { gte: tokenAcceptedAt }, + validUntilTick: { gte: BigInt(options.processingGameTick) }, }, })) as NpcSelectionTokenRow | null; if (!token) { @@ -501,7 +549,7 @@ export const possessNpcGeneral = async (options: { return fail('NOT_FOUND', '장수 등록에 실패했습니다.'); } - const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, acceptedAt); + const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, requestedAtWall); world.updateGeneral(generalId, { userId, npcState: 1, @@ -522,7 +570,7 @@ export const possessNpcGeneral = async (options: { where: { generalId }, update: { userId, - lastRefresh: acceptedAt, + lastRefresh: requestedAtWall, refresh: 0, refreshTotal: 0, refreshScore: 0, @@ -531,7 +579,7 @@ export const possessNpcGeneral = async (options: { create: { generalId, userId, - lastRefresh: acceptedAt, + lastRefresh: requestedAtWall, }, }); await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId } }); diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index b02f0fec..3c42c6c2 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -1,4 +1,9 @@ -import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra'; +import { + createGamePostgresConnector, + GamePrisma, + type InputJsonValue, + type TurnEngineDatabaseClient, +} from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; import { randomUUID } from 'node:crypto'; @@ -75,6 +80,7 @@ const buildTurnListFromRows = ( const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`; type ReservedTurnDatabaseClient = Pick & { + $queryRaw?(query: GamePrisma.Sql): Promise; generalTurnRevision?: Pick< NonNullable, 'findUnique' | 'createMany' | 'updateMany' @@ -313,8 +319,17 @@ export class InMemoryReservedTurnStore { } } - private getLeaseExpiresAt(): Date { - return new Date(Date.now() + this.leaseDurationMs); + private getLeaseExpiresAt(nowWall: Date): Date { + return new Date(nowWall.getTime() + this.leaseDurationMs); + } + + private async readDatabaseWallTime(prisma: ReservedTurnDatabaseClient = this.prisma): Promise { + if (!prisma.$queryRaw) return new Date(); + const rows = await prisma.$queryRaw>(GamePrisma.sql` + SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "nowWall" + `); + if (!rows[0]) throw new Error('PostgreSQL did not return its authoritative wall clock.'); + return rows[0].nowWall; } private async acquireGeneralLease(generalId: number): Promise { @@ -322,7 +337,7 @@ export class InMemoryReservedTurnStore { if (!revisionStore) { return false; } - const now = new Date(); + const now = await this.readDatabaseWallTime(); const previous = (await revisionStore.findUnique({ where: { generalId } })) as { leaseOwner: string | null; leaseExpiresAt: Date | null; @@ -331,7 +346,7 @@ export class InMemoryReservedTurnStore { previous?.leaseOwner === this.leaseOwner && previous.leaseExpiresAt !== null && previous.leaseExpiresAt.getTime() > now.getTime(); - const leaseExpiresAt = this.getLeaseExpiresAt(); + const leaseExpiresAt = this.getLeaseExpiresAt(now); let claimed = await revisionStore.updateMany({ where: { generalId, @@ -372,7 +387,7 @@ export class InMemoryReservedTurnStore { if (!revisionStore) { return false; } - const now = new Date(); + const now = await this.readDatabaseWallTime(); const previous = (await revisionStore.findUnique({ where: { nationId_officerLevel: { nationId, officerLevel } }, })) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null; @@ -380,7 +395,7 @@ export class InMemoryReservedTurnStore { previous?.leaseOwner === this.leaseOwner && previous.leaseExpiresAt !== null && previous.leaseExpiresAt.getTime() > now.getTime(); - const leaseExpiresAt = this.getLeaseExpiresAt(); + const leaseExpiresAt = this.getLeaseExpiresAt(now); let claimed = await revisionStore.updateMany({ where: { nationId, @@ -651,12 +666,13 @@ export class InMemoryReservedTurnStore { if (!revisionStore) { return false; } - const leaseExpiresAt = this.getLeaseExpiresAt(); + const now = await this.readDatabaseWallTime(prisma); + const leaseExpiresAt = this.getLeaseExpiresAt(now); const where = this.leasedGeneralIds.has(generalId) ? { generalId, leaseOwner: this.leaseOwner } : { generalId, - OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }], + OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }], }; let claimed = await revisionStore.updateMany({ where, @@ -721,13 +737,14 @@ export class InMemoryReservedTurnStore { if (!revisionStore) { return false; } - const leaseExpiresAt = this.getLeaseExpiresAt(); + const now = await this.readDatabaseWallTime(prisma); + const leaseExpiresAt = this.getLeaseExpiresAt(now); const where = this.leasedNationKeys.has(key) ? { nationId, officerLevel, leaseOwner: this.leaseOwner } : { nationId, officerLevel, - OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }], + OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }], }; let claimed = await revisionStore.updateMany({ where, diff --git a/app/game-engine/src/turn/runtimeClockShift.ts b/app/game-engine/src/turn/runtimeClockShift.ts index e8311a3f..43db1a28 100644 --- a/app/game-engine/src/turn/runtimeClockShift.ts +++ b/app/game-engine/src/turn/runtimeClockShift.ts @@ -1,5 +1,6 @@ import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import { buildGameEventChannel, @@ -89,8 +90,8 @@ const shiftTournamentClock = async ( }; const lockKey = `${stateKey}:mutation-lock`; const token = randomUUID(); - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { + const deadline = performance.now() + 2_000; + while (performance.now() < deadline) { const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 }); if (acquired) { try { diff --git a/app/game-engine/src/turn/runtimeGameSettings.ts b/app/game-engine/src/turn/runtimeGameSettings.ts index 74332427..5499ecfe 100644 --- a/app/game-engine/src/turn/runtimeGameSettings.ts +++ b/app/game-engine/src/turn/runtimeGameSettings.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import { buildGameEventChannel, @@ -158,8 +159,8 @@ const reprojectTournamentClock = async ( }; const lockKey = `${stateKey}:mutation-lock`; const token = randomUUID(); - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { + const deadline = performance.now() + 2_000; + while (performance.now() < deadline) { const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 }); if (acquired) { try { diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index 9352404e..dc7b6b11 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -268,13 +268,10 @@ const toReservationDto = ( world: InMemoryTurnWorld ): Promise => { const first = rows[0]; - if (!first || (first.reservedUntilTick === null && first.reservedUntil === null)) { + if (!first || first.reservedUntilTick === null) { throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.'); } - const expiresAt = - first.reservedUntilTick === null - ? first.reservedUntil! - : world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName)); + const expiresAt = world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName)); const poolName = resolvePoolName(worldState); if (!poolName || !SUPPORTED_POOLS.has(poolName)) { throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다'); @@ -355,6 +352,23 @@ const readNextChangeAt = (generalMeta: unknown): Date | null => { return Number.isNaN(parsed.getTime()) ? null : parsed; }; +const readNextChangeTick = (generalMeta: unknown): number | null => { + const raw = asRecord(generalMeta).next_change_tick; + const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN; + return Number.isSafeInteger(parsed) ? parsed : null; +}; + +const assertReselectionCooldown = (generalMeta: unknown, processingGameTick: number): void => { + const projection = readNextChangeAt(generalMeta); + const deadline = readNextChangeTick(generalMeta); + if (projection && deadline === null) { + fail('INTERNAL_SERVER_ERROR', '장수 재선택 cooldown의 GAME_TIME authority가 없습니다.'); + } + if (deadline !== null && deadline > processingGameTick) { + fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다'); + } +}; + const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => { const meta = asRecord(worldState.meta); const value = meta.hiddenSeed ?? meta.seed; @@ -371,18 +385,8 @@ const toSafeReservationTick = (value: bigint | number, uniqueName: string): numb return tick; }; -const resolveAcceptedGameTick = (world: InMemoryTurnWorld, now: Date): number => { - const tick = world.dateToGameTick(now); - if (!Number.isSafeInteger(tick)) { - fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.'); - } - return tick; -}; - -const isReservationActive = (row: SelectPoolRow, now: Date, nowTick: number): boolean => - row.reservedUntilTick !== null - ? toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick - : row.reservedUntil !== null && row.reservedUntil.getTime() >= now.getTime(); +const isReservationActive = (row: SelectPoolRow, nowTick: number): boolean => + row.reservedUntilTick !== null && toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick; const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise => { await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`); @@ -392,7 +396,6 @@ const requireSelectionToken = async ( db: DatabaseClient, userId: string, uniqueName: string, - now: Date, nowTick: number ): Promise => { const token = await db.selectPoolEntry.findFirst({ @@ -400,10 +403,7 @@ const requireSelectionToken = async ( ownerUserId: userId, uniqueName, generalId: null, - OR: [ - { reservedUntilTick: { gte: BigInt(nowTick) } }, - { reservedUntilTick: null, reservedUntil: { gte: now } }, - ], + reservedUntilTick: { gte: BigInt(nowTick) }, }, }); if (!token) { @@ -438,18 +438,13 @@ export const reserveSelectionPool = async (options: { worldState: WorldStateRow; userId: string; now?: Date; - acceptedGameTick?: number; - processingGameTick?: number; + processingGameTick: number; seedOwnerIdentity?: string | number; }): Promise => { const { db, world, worldState, userId } = options; requirePoolWorld(worldState); const now = options.now ?? new Date(); - const acceptedGameTick = options.acceptedGameTick ?? resolveAcceptedGameTick(world, now); - const processingGameTick = options.processingGameTick ?? acceptedGameTick; - if (!Number.isSafeInteger(acceptedGameTick)) { - fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.'); - } + const processingGameTick = options.processingGameTick; if (!Number.isSafeInteger(processingGameTick)) { fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.'); } @@ -459,14 +454,11 @@ export const reserveSelectionPool = async (options: { where: { userId }, select: { id: true, meta: true }, }); - const nextChangeAt = general ? readNextChangeAt(general.meta) : null; - if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) { - fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다'); - } + if (general) assertReselectionCooldown(general.meta, processingGameTick); let currentRows = await synchronizeSelectionPoolWorld(db, world); const existing = currentRows.filter( - (row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, now, processingGameTick) + (row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, processingGameTick) ); if (existing.length > 0) { return toReservationDto(existing, Boolean(general), worldState, world); @@ -477,7 +469,7 @@ export const reserveSelectionPool = async (options: { generalId: null, OR: [ { reservedUntilTick: { lt: BigInt(processingGameTick) } }, - { reservedUntilTick: null, reservedUntil: { lt: now } }, + { reservedUntilTick: null, reservedUntil: { not: null } }, ], }, data: { @@ -504,7 +496,7 @@ export const reserveSelectionPool = async (options: { const rng = new RandUtil( new LiteHashDRBG( - buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, acceptedGameTick) + buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, processingGameTick) ) ); const poolName = resolvePoolName(worldState)!; @@ -578,7 +570,6 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor const clearUnusedReservations = async ( db: DatabaseClient, userId: string, - now: Date, nowTick: number ): Promise => { await db.selectPoolEntry.updateMany({ @@ -587,7 +578,7 @@ const clearUnusedReservations = async ( OR: [ { ownerUserId: userId }, { reservedUntilTick: { lt: BigInt(nowTick) } }, - { reservedUntilTick: null, reservedUntil: { lt: now } }, + { reservedUntilTick: null, reservedUntil: { not: null } }, ], }, data: { @@ -716,6 +707,7 @@ export const createGeneralFromSelectionPool = async (options: { now?: Date; turnScheduleAt?: Date; operationalAcceptedAt: Date; + processingGameTick: number; seedOwnerIdentity?: string | number; ownerPicture?: string; ownerImageServer?: number; @@ -724,7 +716,10 @@ export const createGeneralFromSelectionPool = async (options: { const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options; requirePoolWorld(worldState); const now = options.now ?? new Date(); - const nowTick = resolveAcceptedGameTick(world, now); + const nowTick = options.processingGameTick; + if (!Number.isSafeInteger(nowTick)) { + fail('INTERNAL_SERVER_ERROR', '장수 생성 처리 tick이 안전한 정수 범위를 벗어났습니다.'); + } await lockSelectionUser(db, userId); await lockSelectionMutationTables(db); await synchronizeSelectionPoolWorld(db, world); @@ -735,7 +730,7 @@ export const createGeneralFromSelectionPool = async (options: { ) { fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.'); } - const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick); + const token = await requireSelectionToken(db, userId, uniqueName, nowTick); const info = parseCandidate(token); const poolName = resolvePoolName(worldState)!; const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL; @@ -777,9 +772,8 @@ export const createGeneralFromSelectionPool = async (options: { const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now); const age = 20; const specialityAges = resolveSpecialityAges(worldState, age); - const nextChangeAt = new Date( - now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 - ); + const nextChangeTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN; + const nextChangeAt = world.gameTickToDate(nextChangeTick); const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config); // 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는 // 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다. @@ -813,6 +807,7 @@ export const createGeneralFromSelectionPool = async (options: { dex5: isCentennial ? 0 : info.dex[4], next_change: nextChangeAt.toISOString(), nextChangeAt: nextChangeAt.toISOString(), + next_change_tick: nextChangeTick, prestart_delete_after: prestartDeleteAfter.toISOString(), ...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}), npc_org: 0, @@ -905,10 +900,7 @@ export const createGeneralFromSelectionPool = async (options: { id: token.id, ownerUserId: userId, generalId: null, - OR: [ - { reservedUntilTick: { gte: BigInt(nowTick) } }, - { reservedUntilTick: null, reservedUntil: { gte: now } }, - ], + reservedUntilTick: { gte: BigInt(nowTick) }, }, data: { generalId, @@ -925,7 +917,7 @@ export const createGeneralFromSelectionPool = async (options: { update: { userId, lastRefresh: options.operationalAcceptedAt }, create: { generalId, userId, lastRefresh: options.operationalAcceptedAt }, }); - await clearUnusedReservations(db, userId, now, nowTick); + await clearUnusedReservations(db, userId, nowTick); await synchronizeSelectionPoolWorld(db, world); const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이'); @@ -949,11 +941,15 @@ export const reselectGeneralFromSelectionPool = async (options: { ownerDisplayName: string; uniqueName: string; now?: Date; + processingGameTick: number; }): Promise<{ ok: true; generalId: number }> => { const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options; requirePoolWorld(worldState); const now = options.now ?? new Date(); - const nowTick = resolveAcceptedGameTick(world, now); + const nowTick = options.processingGameTick; + if (!Number.isSafeInteger(nowTick)) { + fail('INTERNAL_SERVER_ERROR', '장수 재선택 처리 tick이 안전한 정수 범위를 벗어났습니다.'); + } await lockSelectionUser(db, userId); await lockSelectionMutationTables(db); await synchronizeSelectionPoolWorld(db, world); @@ -968,11 +964,8 @@ export const reselectGeneralFromSelectionPool = async (options: { if (persistedGeneral.id !== general.id) { fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.'); } - const nextChangeAt = readNextChangeAt(general.meta); - if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) { - fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다'); - } - const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick); + assertReselectionCooldown(general.meta, nowTick); + const token = await requireSelectionToken(db, userId, uniqueName, nowTick); const info = parseCandidate(token); const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL; @@ -982,10 +975,7 @@ export const reselectGeneralFromSelectionPool = async (options: { id: token.id, ownerUserId: userId, generalId: null, - OR: [ - { reservedUntilTick: { gte: BigInt(nowTick) } }, - { reservedUntilTick: null, reservedUntil: { gte: now } }, - ], + reservedUntilTick: { gte: BigInt(nowTick) }, }, data: { generalId: provisionalGeneralId, @@ -1014,9 +1004,8 @@ export const reselectGeneralFromSelectionPool = async (options: { throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.'); } - const cooldown = new Date( - now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 - ); + const cooldownTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN; + const cooldown = world.gameTickToDate(cooldownTick); const centennialBaseGeneral = isCentennial ? { ...general, @@ -1046,6 +1035,7 @@ export const reselectGeneralFromSelectionPool = async (options: { : {}), next_change: cooldown.toISOString(), nextChangeAt: cooldown.toISOString(), + next_change_tick: cooldownTick, ...buildScenarioGeneralPoolClaimMeta( parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }), now @@ -1074,7 +1064,7 @@ export const reselectGeneralFromSelectionPool = async (options: { if (!updated) { throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.'); } - await clearUnusedReservations(db, userId, now, nowTick); + await clearUnusedReservations(db, userId, nowTick); await synchronizeSelectionPoolWorld(db, world); const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이'); diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 8b6db860..62ea23be 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -1,5 +1,9 @@ import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; -import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra'; +import { + acquireGameSchemaAdvisoryXactLock, + enqueuePrivateMessageWebPush, + persistMessageEnvelope, +} from '@sammo-ts/infra'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic'; import { @@ -119,22 +123,18 @@ interface HighestUnificationBidRow { meta: unknown; } -const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: MessageRecordDraft): Promise => { - const rows = await transaction.$queryRaw>` - INSERT INTO message (mailbox, type, src, dest, time, valid_until, message) - VALUES ( - ${draft.mailbox}, - ${draft.msgType}, - ${draft.srcId}, - ${draft.destId}, - ${draft.time}, - ${draft.validUntil}, - CAST(${JSON.stringify(draft.payload)} AS jsonb) - ) - RETURNING id - `; - const id = rows[0]?.id; - if (!id) throw new Error('Failed to persist unification auction cancellation message.'); +const insertMessage = async ( + transaction: GamePrisma.TransactionClient, + world: InMemoryTurnWorld, + draft: MessageRecordDraft +): Promise => { + const clock = world.getGameClockState(); + const id = await persistMessageEnvelope(transaction, draft, { + occurredGameTick: BigInt(world.dateToGameTick(draft.time)), + clockRevision: BigInt(clock.revision), + deadlineGeneration: BigInt(clock.deadlineGeneration), + expiresGameTick: null, + }); await enqueuePrivateMessageWebPush(transaction, draft, id); return id; }; @@ -252,7 +252,7 @@ const cancelPendingUniqueAuctions = async ( await sendMessage( { insertMessage: async (draft) => { - const messageId = await insertMessage(transaction, draft); + const messageId = await insertMessage(transaction, world, draft); messageMailboxes.push(draft.mailbox); return messageId; }, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 0ccda2cc..5e2a4210 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -278,18 +278,12 @@ const resolveSelectionCommandAcceptedAt = async ( world: InMemoryTurnWorld, command: Extract ): Promise => { - const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); + await resolveCommandAcceptedAt(db, command); const processingGameTick = Reflect.get(command, 'processingGameTick'); if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) { return world.gameTickToDate(processingGameTick); } - if (command.acceptedGameTick !== undefined) { - return world.gameTickToDate(command.acceptedGameTick); - } - if (command.acceptedGameAt !== undefined) { - return new Date(command.acceptedGameAt); - } - return world.getGameNow(operationalAcceptedAt); + throw new Error(`${command.type} requires an authoritative daemon processing game tick.`); }; const resolveOperationalAcceptedAt = async ( @@ -417,12 +411,9 @@ async function handleNpcPossessGeneral( } const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); const processingGameTick = Reflect.get(command, 'processingGameTick'); - const acceptedAt = - typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick) - ? ctx.world.gameTickToDate(processingGameTick) - : command.acceptedGameAt - ? new Date(command.acceptedGameAt) - : ctx.world.getGameNow(operationalAcceptedAt); + if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) { + throw new Error('npcPossessGeneral requires an authoritative daemon processing game tick.'); + } try { return { type: 'npcPossessGeneral', @@ -436,7 +427,8 @@ async function handleNpcPossessGeneral( ...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}), generalId: command.generalId, tokenNonce: command.tokenNonce, - acceptedAt, + requestedAtWall: operationalAcceptedAt, + processingGameTick, })), }; } catch (error) { @@ -465,15 +457,11 @@ async function handleSelectPoolCreate( } const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); const processingGameTick = Reflect.get(command, 'processingGameTick'); - const acceptedAt = - typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick) - ? ctx.world.gameTickToDate(processingGameTick) - : command.acceptedGameTick !== undefined - ? ctx.world.gameTickToDate(command.acceptedGameTick) - : command.acceptedGameAt !== undefined - ? new Date(command.acceptedGameAt) - : ctx.world.getGameNow(operationalAcceptedAt); - const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt); + if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) { + throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.'); + } + const acceptedAt = ctx.world.gameTickToDate(processingGameTick); + const turnScheduleAt = acceptedAt; try { return { type: 'selectPoolCreate', @@ -492,6 +480,7 @@ async function handleSelectPoolCreate( now: acceptedAt, turnScheduleAt, operationalAcceptedAt, + processingGameTick, })), }; } catch (error) { @@ -530,10 +519,7 @@ async function handleSelectPoolReserve( userId: command.userId, seedOwnerIdentity: command.seedOwnerIdentity, now: acceptedAt, - ...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }), - ...(typeof Reflect.get(command, 'processingGameTick') === 'number' - ? { processingGameTick: Reflect.get(command, 'processingGameTick') as number } - : {}), + processingGameTick: Reflect.get(command, 'processingGameTick') as number, }), }; } catch (error) { @@ -572,6 +558,7 @@ async function handleSelectPoolReselect( ownerDisplayName: command.ownerDisplayName, uniqueName: command.uniqueName, now: acceptedAt, + processingGameTick: Reflect.get(command, 'processingGameTick') as number, })), }; } catch (error) { @@ -2745,17 +2732,16 @@ type VotePollValidationRow = { export const hasVotePollDeadlinePassed = ( poll: Pick, - acceptedGameAt: Date, - acceptedGameTick: number + currentGameTick: number ): boolean => { const endTick = poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick); - return ( - poll.closedAt !== null || - (endTick !== null - ? endTick < BigInt(acceptedGameTick) - : Boolean(poll.endAt && poll.endAt.getTime() < acceptedGameAt.getTime())) - ); + if (poll.closedAt !== null) return true; + // No projection and no tick means an intentionally unbounded poll. A + // projection without its authoritative tick is a broken GAME deadline and + // therefore fails closed. + if (endTick === null) return poll.endAt !== null; + return endTick < BigInt(currentGameTick); }; const parseVoteOptionCount = (value: unknown): number => { @@ -2790,17 +2776,11 @@ const validateVoteSelectionInTransaction = async ( const poll = rows[0]; if (!poll) return '설문조사가 없습니다.'; - const processingNow = ctx.world.getGameNow(new Date()); const convertedProcessingTick = Reflect.get(command, 'processingGameTick'); - const acceptedGameTick = - typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick) - ? convertedProcessingTick - : (command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow)); - const acceptedGameAt = - command.acceptedGameTick === undefined && convertedProcessingTick === undefined - ? processingNow - : ctx.world.gameTickToDate(acceptedGameTick); - if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) { + if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) { + throw new Error('voteReward requires an authoritative daemon processing game tick.'); + } + if (hasVotePollDeadlinePassed(poll, convertedProcessingTick)) { return '설문조사가 종료되었습니다.'; } diff --git a/app/game-engine/test/actionableMessageResponse.test.ts b/app/game-engine/test/actionableMessageResponse.test.ts index dc579844..ead7bdf3 100644 --- a/app/game-engine/test/actionableMessageResponse.test.ts +++ b/app/game-engine/test/actionableMessageResponse.test.ts @@ -87,6 +87,7 @@ const destination = { color: '#ffffff', icon: '', }; +const requestId = 'actionable-message-request'; const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial = {}) => ({ id: 29, @@ -94,6 +95,10 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial { const queryRaw = vi.fn(async () => rows.shift() ?? []); const updateMany = vi.fn(async () => ({ count: 1 })); + const actionUpdateMany = vi.fn(async () => ({ count: 1 })); return { - db: { $queryRaw: queryRaw, message: { updateMany } } as unknown as GamePrisma.TransactionClient, + db: { + $queryRaw: queryRaw, + inputEvent: { + findUnique: vi.fn(async () => ({ + actorUserId: actor.userId, + target: 'ENGINE', + eventType: 'messageRespond', + createdAt: new Date('2026-09-03T00:00:00.000Z'), + processingGameTick: 0n, + })), + }, + message: { updateMany }, + messageAction: { updateMany: actionUpdateMany }, + } as unknown as GamePrisma.TransactionClient, queryRaw, updateMany, + actionUpdateMany, }; }; @@ -118,6 +138,22 @@ const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({ }); describe('actionable message response', () => { + it('rejects a response without the authoritative durable command boundary', async () => { + const world = buildWorld(); + const { db } = buildDb([[buildRow('scout')]]); + await expect( + respondToActionableMessage({ + db, + world, + executor: buildExecutor(), + userId: actor.userId!, + generalId: actor.id, + messageId: 29, + response: true, + }) + ).rejects.toThrow('durable ENGINE input event requestId'); + }); + it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => { const world = buildWorld(); const row = buildRow('scout'); @@ -128,6 +164,7 @@ describe('actionable message response', () => { db, world, executor, + requestId, userId: actor.userId!, generalId: actor.id, messageId: row.id, @@ -162,6 +199,7 @@ describe('actionable message response', () => { db, world, executor: buildExecutor(false), + requestId, userId: actor.userId!, generalId: actor.id, messageId: row.id, @@ -173,14 +211,8 @@ describe('actionable message response', () => { expect(world.peekDirtyState().messages).toHaveLength(0); }); - it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => { - for (const row of [ - buildRow('scout', { option: { action: 'scout', used: 1 } }), - { - ...buildRow('scout'), - validUntil: new Date('0199-12-31T23:59:59.000Z'), - }, - ]) { + it('treats a legacy truthy used value as an invalid scout letter', async () => { + for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) { const world = buildWorld(); const { db, updateMany } = buildDb([[row]]); const executor = buildExecutor(); @@ -190,6 +222,7 @@ describe('actionable message response', () => { db, world, executor, + requestId, userId: actor.userId!, generalId: actor.id, messageId: row.id, @@ -201,6 +234,24 @@ describe('actionable message response', () => { } }); + it('treats an expired GAME_TIME action row as absent', async () => { + const world = buildWorld(); + const { db } = buildDb([[]]); + + await expect( + respondToActionableMessage({ + db, + world, + executor: buildExecutor(), + requestId, + userId: actor.userId!, + generalId: actor.id, + messageId: 29, + response: true, + }) + ).resolves.toEqual({ ok: false, reason: '존재하지 않는 메시지입니다.' }); + }); + it("keeps PHP's special string-zero used value false", async () => { const world = buildWorld(); const row = buildRow('scout', { option: { action: 'scout', used: '0' } }); @@ -212,6 +263,7 @@ describe('actionable message response', () => { db, world, executor, + requestId, userId: actor.userId!, generalId: actor.id, messageId: row.id, @@ -233,6 +285,7 @@ describe('actionable message response', () => { db, world, executor, + requestId, userId: actor.userId!, generalId: actor.id, messageId: row.id, @@ -252,6 +305,7 @@ describe('actionable message response', () => { db, world, executor: buildExecutor(), + requestId, userId: actor.userId!, generalId: actor.id, messageId: row.id, @@ -271,6 +325,7 @@ describe('actionable message response', () => { db, world, executor: buildExecutor(), + requestId, userId: actor.userId!, generalId: actor.id, messageId: row.id, diff --git a/app/game-engine/test/auctionBidCompatibility.test.ts b/app/game-engine/test/auctionBidCompatibility.test.ts index ea4a9d0a..ed67afd4 100644 --- a/app/game-engine/test/auctionBidCompatibility.test.ts +++ b/app/game-engine/test/auctionBidCompatibility.test.ts @@ -107,6 +107,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => { world: world as unknown as Parameters[0]['world'], }); const amount = finishImmediately ? 500 : 200; + const requestedAtWall = new Date('2026-08-23T00:00:00.000Z'); const result = await auctionBidder.bid( { type: 'auctionBid', @@ -114,8 +115,9 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => { auctionId: 31, generalId: general.id, amount, - acceptedGameTick: 100, - }, + processingGameTick: 100, + requestedAtWall, + } as any, commandDb as any ); await auctionBidder.close(); @@ -125,7 +127,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => { ); const insert = statements.find((query) => query.strings.join(' ').includes('INSERT INTO auction_bid')); const update = statements.find((query) => query.strings.join(' ').includes('UPDATE auction')); - return { acceptedAt, processingAt, result, insert, update }; + return { acceptedAt, processingAt, requestedAtWall, result, insert, update }; }; describe('resource auction Ref compatibility', () => { @@ -135,21 +137,19 @@ describe('resource auction Ref compatibility', () => { expect(hasAuctionClosePassed(auction, closeAt, 72_000_000)).toBe(false); expect(hasAuctionClosePassed(auction, new Date(closeAt.getTime() + 1), 72_000_001)).toBe(true); - expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(false); + expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(true); expect(hasAuctionClosePassed({ closeAt, closeTick: null }, new Date(closeAt.getTime() + 1), null)).toBe(true); }); - it('uses the durable API acceptance tick when queue processing crosses the close boundary', () => { + it('uses only the authoritative daemon processing tick at the close boundary', () => { const closeAt = new Date('0190-02-01T00:00:00.000Z'); const auction = { closeAt, closeTick: 72_000_000n }; const world = { dateToGameTick: () => 72_000_001, gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)), }; - const processingNow = new Date(closeAt.getTime() + 1); - - expect(hasAuctionBidClosePassed(auction, world, processingNow, 72_000_000)).toBe(false); - expect(hasAuctionBidClosePassed(auction, world, processingNow)).toBe(true); + expect(hasAuctionBidClosePassed(auction, world, 72_000_000)).toBe(false); + expect(hasAuctionBidClosePassed(auction, world, 72_000_001)).toBe(true); expect( normalizeTurnDaemonCommand({ requestId: 'auction-bid-accepted-tick', @@ -161,23 +161,26 @@ describe('resource auction Ref compatibility', () => { generalId: 7, amount: 500, acceptedGameTick: 72_000_000, - }, + } as any, }) - ).toMatchObject({ acceptedGameTick: 72_000_000 }); + ).not.toHaveProperty('acceptedGameTick'); }); it('uses the accepted logical time for delayed extension and persisted bid timestamps', async () => { - const { acceptedAt, processingAt, result, insert, update } = await runDelayedResourceBid(false); + const { acceptedAt, processingAt, requestedAtWall, result, insert, update } = + await runDelayedResourceBid(false); expect(result).toMatchObject({ type: 'auctionBid', ok: true }); expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe( acceptedAt.getTime() + 100_000 ); - expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([acceptedAt]); + expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([ + acceptedAt, + requestedAtWall, + ]); expect(update?.values.filter((value): value is Date => value instanceof Date)).toEqual([ new Date(acceptedAt.getTime() + 100_000), acceptedAt, - acceptedAt, ]); expect(update?.values).not.toContain(processingAt); }); diff --git a/app/game-engine/test/auctionFinalizerCompatibility.test.ts b/app/game-engine/test/auctionFinalizerCompatibility.test.ts index d07303a8..cebf811d 100644 --- a/app/game-engine/test/auctionFinalizerCompatibility.test.ts +++ b/app/game-engine/test/auctionFinalizerCompatibility.test.ts @@ -27,6 +27,12 @@ import { import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js'; import type { TurnGeneral } from '../src/turn/types.js'; +const withDaemonBoundary = (command: T, processingGameTick = 72_000_000): T => + Object.assign(command, { + processingGameTick, + requestedAtWall: new Date('2026-09-03T00:00:00.000Z'), + }); + describe('unique auction inheritance log compatibility', () => { it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => { const userId = '4c2f2f6d-8a37-4f22-a4f9-1a6f5e4c22ec'; @@ -113,19 +119,20 @@ describe('unique auction inheritance log compatibility', () => { }), getGameNow: () => new Date('0193-07-01T00:00:00.000Z'), dateToGameTick: (date: Date) => Math.floor(date.getTime() / 1_000), + gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'), updateGeneral: (_id: number, patch: Partial) => Object.assign(general, patch), pushLog: () => {}, }; const result = await openAuction( - { + withDaemonBoundary({ type: 'auctionOpen', userId: 'user-7', auctionType: 'UNIQUE_ITEM', generalId: general.id, amount: 6_000, itemKey: 'che_무기_12_칠성검', - }, + }), world as unknown as Parameters[1], db as unknown as NonNullable[2]> ); @@ -192,6 +199,7 @@ describe('unique auction inheritance log compatibility', () => { const world = { getGameNow: () => closeAt, dateToGameTick: () => 72_000_000, + gameTickToDate: () => closeAt, pushLog: vi.fn(), }; const finalizer = await createAuctionFinalizer({ @@ -201,12 +209,12 @@ describe('unique auction inheritance log compatibility', () => { await expect( finalizer.finalize( - { + withDaemonBoundary({ type: 'auctionFinalize', auctionId: 31, expectedCloseAt: closeAt.toISOString(), expectedCloseTick: 72_000_000, - }, + }), commandDb as unknown as NonNullable[1]> ) ).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 }); @@ -241,6 +249,7 @@ describe('unique auction inheritance log compatibility', () => { const world = { getGameNow: () => closeAt, dateToGameTick: () => nowTick, + gameTickToDate: () => closeAt, }; const finalizer = await createAuctionFinalizer({ databaseUrl: 'postgresql://unused', @@ -249,11 +258,23 @@ describe('unique auction inheritance log compatibility', () => { const db = commandDb as unknown as NonNullable[1]>; await expect( - finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }, db) + finalizer.finalize( + withDaemonBoundary( + { type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }, + 71_999_999 + ), + db + ) ).resolves.toMatchObject({ ok: false, reason: '경매 마감 시각이 아직 지나지 않았습니다.' }); nowTick = 72_000_000; await expect( - finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 }, db) + finalizer.finalize( + withDaemonBoundary( + { type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 }, + 72_000_000 + ), + db + ) ).resolves.toMatchObject({ ok: false, reason: '경매 마감 세대가 변경되었습니다.' }); expect(executeRaw).not.toHaveBeenCalled(); @@ -276,12 +297,16 @@ describe('unique auction inheritance log compatibility', () => { detail: { amount: 100 }, status: 'OPEN', closeAt, - closeTick: null, + closeTick: 72_000_000n, }, ]; }); const commandDb = { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 0) }; - const world = { getGameNow: () => closeAt, dateToGameTick: () => 72_000_000 }; + const world = { + getGameNow: () => closeAt, + dateToGameTick: () => 72_000_000, + gameTickToDate: () => closeAt, + }; const finalizer = await createAuctionFinalizer({ databaseUrl: 'postgresql://unused', world: world as unknown as Parameters[0]['world'], @@ -289,7 +314,12 @@ describe('unique auction inheritance log compatibility', () => { await expect( finalizer.finalize( - { type: 'auctionFinalize', auctionId: 31, expectedCloseAt: closeAt.toISOString() }, + withDaemonBoundary({ + type: 'auctionFinalize', + auctionId: 31, + expectedCloseAt: closeAt.toISOString(), + expectedCloseTick: 72_000_000, + }), commandDb as unknown as NonNullable[1]> ) ).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31'); @@ -317,6 +347,7 @@ describe('unique auction inheritance log compatibility', () => { const queueMessage = vi.fn(); const world = { getGameNow: () => new Date('0193-07-01T00:00:00.000Z'), + gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'), getGeneralById: (id: number) => (id === bidder.id ? bidder : id === host.id ? host : null), getNationById: () => ({ name: '촉', color: '#ff0000' }), updateGeneral, @@ -350,7 +381,7 @@ describe('unique auction inheritance log compatibility', () => { await expect( finalizer.finalize( - { type: 'auctionFinalize', auctionId: 31 }, + withDaemonBoundary({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }), commandDb as unknown as NonNullable[1]> ) ).resolves.toMatchObject({ diff --git a/app/game-engine/test/clockReconciliation.integration.test.ts b/app/game-engine/test/clockReconciliation.integration.test.ts index df44f46b..f8518338 100644 --- a/app/game-engine/test/clockReconciliation.integration.test.ts +++ b/app/game-engine/test/clockReconciliation.integration.test.ts @@ -79,6 +79,7 @@ describeIntegration('durable clock reconciliation', () => { revision: 1, }); const generalTicks = [initialTick + 1_234, initialTick + 36_000_123]; + const reselectionTick = initialTick + 54_000_456; const auctionCloseTick = initialTick + 72_000_777; const messageOccurrenceTick = initialTick - 500; const messageExpiryTick = initialTick + 90_000_999; @@ -116,6 +117,14 @@ describeIntegration('durable clock reconciliation', () => { turnTime: clock.tickToDate(turnTick), recentWarTick: BigInt(initialTick - 100 - index), recentWarTime: clock.tickToDate(initialTick - 100 - index), + meta: + index === 0 + ? { + next_change_tick: reselectionTick, + next_change: clock.tickToDate(reselectionTick).toISOString(), + nextChangeAt: clock.tickToDate(reselectionTick).toISOString(), + } + : {}, })), }); await db.auction.create({ @@ -138,7 +147,20 @@ describeIntegration('durable clock reconciliation', () => { timeTick: BigInt(messageOccurrenceTick), validUntil: clock.tickToDate(messageExpiryTick), validUntilTick: BigInt(messageExpiryTick), + createdAtWall: new Date('2026-01-01T12:34:56.789Z'), + deleteUntilWall: new Date('2026-01-01T12:39:56.789Z'), + occurredGameTick: BigInt(messageOccurrenceTick), message: {}, + action: { + create: { + actionType: 'scout', + status: 'PENDING', + createdGameTick: BigInt(messageOccurrenceTick), + expiresGameTick: BigInt(messageExpiryTick), + clockRevision: 1n, + deadlineGeneration: 7n, + }, + }, }, }); await db.votePoll.create({ @@ -201,17 +223,19 @@ describeIntegration('durable clock reconciliation', () => { alignedTick: 236_035_000, }); - const [afterWorld, generals, auction, message, vote, pool, token, ledger, outboxes] = await Promise.all([ + 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(), - ]); + ]); const alignedTick = BigInt(reconciled.alignedTick); expect(afterWorld).toMatchObject({ clockPhase: 'RECONCILING', @@ -223,8 +247,19 @@ describeIntegration('durable clock reconciliation', () => { expect(generals.map((general) => general.turnTick! - alignedTick)).toEqual( generalTicks.map((tick) => BigInt(tick - initialTick)) ); + const shiftedReselectionMeta = generals[0]!.meta as Record; + expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks); + expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe( + clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250 + ); expect(auction.closeTick! - alignedTick).toBe(BigInt(auctionCloseTick - initialTick)); expect(message.validUntilTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick)); + expect(messageAction.expiresGameTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick)); + expect(messageAction.createdGameTick).toBe(BigInt(messageOccurrenceTick)); + expect(messageAction.clockRevision).toBe(2n); + expect(messageAction.deadlineGeneration).toBe(8n); + expect(message.createdAtWall).toEqual(new Date('2026-01-01T12:34:56.789Z')); + expect(message.deleteUntilWall).toEqual(new Date('2026-01-01T12:39:56.789Z')); expect(vote.endTick! - alignedTick).toBe(BigInt(voteEndTick - initialTick)); expect(pool.reservedUntilTick! - alignedTick).toBe(BigInt(poolTick - initialTick)); expect(token.validUntilTick! - alignedTick).toBe(BigInt(npcValidTick - initialTick)); @@ -302,6 +337,21 @@ describeIntegration('durable clock reconciliation', () => { await db.general.create({ data: { id: 1, name: 'day-general', turnTick: BigInt(turnTick), turnTime: clock.tickToDate(turnTick) }, }); + const wallMessageCreatedAt = new Date('2026-01-15T12:00:00.000Z'); + const wallMessageDeleteUntil = new Date('2026-01-15T12:05:00.000Z'); + const wallMessage = await db.message.create({ + data: { + mailbox: 0, + type: 'public', + src: 1, + dest: 0, + time: wallMessageCreatedAt, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + createdAtWall: wallMessageCreatedAt, + deleteUntilWall: wallMessageDeleteUntil, + message: { src: {}, dest: {}, text: 'wall clock survives 24h suspension', option: {} }, + }, + }); await db.turnDaemonLease.create({ data: { profile: 'clock-day-test', @@ -346,6 +396,10 @@ describeIntegration('durable clock reconciliation', () => { }); const shifted = await db.general.findUniqueOrThrow({ where: { id: 1 } }); expect(shifted.turnTick! - BigInt(reconciled.alignedTick)).toBe(BigInt(turnTick - initialTick)); + await expect(db.message.findUniqueOrThrow({ where: { id: wallMessage.id } })).resolves.toMatchObject({ + createdAtWall: wallMessageCreatedAt, + deleteUntilWall: wallMessageDeleteUntil, + }); await redis.client.set('sammo:clock-day-test:clock:active-revision', '3'); const redisThenCrash = { diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index 18854667..b140297a 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -25,7 +25,26 @@ integration('database command queue', () => { }); await db.message.deleteMany({ where: { mailbox: 991_199 } }); await db.worldState.deleteMany({ - where: { scenarioCode: { in: ['queue-clock-test', 'queue-unification-clock-test'] } }, + where: { scenarioCode: { in: ['queue-clock-base', 'queue-clock-test', 'queue-unification-clock-test'] } }, + }); + }; + + const createClockFixture = async (): Promise => { + await db.worldState.create({ + data: { + scenarioCode: 'queue-clock-base', + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + clockBaseTime: new Date('0180-01-01T00:00:00.000Z'), + clockTick: 123n, + clockMode: 'manual', + clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'), + lastTurnTick: 123n, + clockPhase: 'MANUAL', + clockRevision: 1n, + deadlineGeneration: 1n, + }, }); }; @@ -39,7 +58,10 @@ integration('database command queue', () => { }); }); - beforeEach(cleanupFixtures); + beforeEach(async () => { + await cleanupFixtures(); + await createClockFixture(); + }); afterAll(async () => { await cleanupFixtures(); @@ -63,7 +85,16 @@ integration('database command queue', () => { const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]); const commands = firstCommands.concat(secondCommands); - expect(commands).toEqual([{ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }]); + expect(commands).toEqual([ + { + type: 'vacation', + requestId, + userId: 'user-7', + generalId: 7, + processingGameTick: 123, + requestedAtWall: expect.any(Date), + }, + ]); await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 }); const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); @@ -118,7 +149,16 @@ integration('database command queue', () => { await queue.initialize(); const commands = await queue.drain(); - expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, userId: 'user-8', generalId: 8 }]); + expect(commands).toEqual([ + { + type: 'vacation', + requestId: expiredId, + userId: 'user-8', + generalId: 8, + processingGameTick: 123, + requestedAtWall: expect.any(Date), + }, + ]); expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({ status: 'PROCESSING', lockedBy: 'active-worker', @@ -146,7 +186,14 @@ integration('database command queue', () => { const stale = new DatabaseTurnDaemonCommandQueue(db); for (const attempt of [1, 2, 3]) { await expect(owner.drain()).resolves.toEqual([ - { type: 'vacation', requestId, userId: 'user-10', generalId: 10 }, + { + type: 'vacation', + requestId, + userId: 'user-10', + generalId: 10, + processingGameTick: 123, + requestedAtWall: expect.any(Date), + }, ]); await stale.publishCommandError(requestId, new Error('stale worker failure')); await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ @@ -220,7 +267,13 @@ integration('database command queue', () => { const owner = new DatabaseTurnDaemonCommandQueue(db); const claimed = await owner.drain(); - expect(claimed).toEqual([command]); + expect(claimed).toEqual([ + { + ...command, + processingGameTick: 123, + requestedAtWall: expect.any(Date), + }, + ]); const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction })); expect(result).toMatchObject({ type: 'commandRejected', @@ -301,7 +354,14 @@ integration('database command queue', () => { }); const queue = new DatabaseTurnDaemonCommandQueue(db); - expect(await queue.drain()).toEqual([{ type: 'getStatus', requestId: statusId }]); + expect(await queue.drain()).toEqual([ + { + type: 'getStatus', + requestId: statusId, + processingGameTick: 100, + requestedAtWall: expect.any(Date), + }, + ]); expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({ status: 'PENDING', processingClockRevision: null, @@ -309,7 +369,14 @@ integration('database command queue', () => { await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } }); expect(await queue.drain()).toEqual([ - { type: 'vacation', requestId: gameplayId, userId: 'user-7', generalId: 7 }, + { + type: 'vacation', + requestId: gameplayId, + userId: 'user-7', + generalId: 7, + processingGameTick: 100, + requestedAtWall: expect.any(Date), + }, ]); expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({ status: 'PROCESSING', @@ -347,6 +414,7 @@ integration('database command queue', () => { userId: 'user-8', generalId: 8, processingGameTick: 123, + requestedAtWall: expect.any(Date), }, ]); expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({ @@ -359,6 +427,103 @@ integration('database command queue', () => { }); }); + it('dequeues only tournament bet accounting commands while the game clock is suspended', async () => { + const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } }); + const world = existingWorld + ? await db.worldState.update({ + where: { id: existingWorld.id }, + data: { clockPhase: 'SUSPENDED', clockRevision: 19n, deadlineGeneration: 6n, clockTick: 321n }, + }) + : await db.worldState.create({ + data: { + scenarioCode: 'queue-clock-test', + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + clockPhase: 'SUSPENDED', + clockRevision: 19n, + deadlineGeneration: 6n, + clockTick: 321n, + }, + }); + const resourceId = 'integration:engine:suspended-tournament-bet-resource'; + const metaId = 'integration:engine:suspended-tournament-bet-meta'; + const rollbackId = 'integration:engine:suspended-tournament-bet-rollback'; + const unrelatedId = 'integration:engine:suspended-resource-adjustment'; + await db.inputEvent.createMany({ + data: [ + { + requestId: resourceId, + target: 'ENGINE', + eventType: 'adjustGeneralResources', + payload: { + type: 'adjustGeneralResources', + requestId: resourceId, + reason: 'tournamentBet', + adjustments: [{ generalId: 7, goldDelta: -100 }], + }, + }, + { + requestId: metaId, + target: 'ENGINE', + eventType: 'adjustGeneralMeta', + payload: { + type: 'adjustGeneralMeta', + requestId: metaId, + reason: 'tournamentBet', + adjustments: [{ generalId: 7, metaDelta: { betgold: 100 } }], + }, + }, + { + requestId: rollbackId, + target: 'ENGINE', + eventType: 'adjustGeneralResources', + payload: { + type: 'adjustGeneralResources', + requestId: rollbackId, + reason: 'tournamentBetRollback', + adjustments: [{ generalId: 7, goldDelta: 100 }], + }, + }, + { + requestId: unrelatedId, + target: 'ENGINE', + eventType: 'adjustGeneralResources', + payload: { + type: 'adjustGeneralResources', + requestId: unrelatedId, + reason: 'otherMutation', + adjustments: [{ generalId: 7, goldDelta: -100 }], + }, + }, + ], + }); + + const queue = new DatabaseTurnDaemonCommandQueue(db); + await expect(queue.drain()).resolves.toEqual([ + expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }), + expect.objectContaining({ type: 'adjustGeneralMeta', requestId: metaId, reason: 'tournamentBet' }), + expect.objectContaining({ + type: 'adjustGeneralResources', + requestId: rollbackId, + reason: 'tournamentBetRollback', + }), + ]); + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({ + status: 'PROCESSING', + processingGameTick: 321n, + processingClockRevision: 19n, + processingDeadlineGeneration: 6n, + }); + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: unrelatedId } })).resolves.toMatchObject({ + status: 'PENDING', + processingClockRevision: null, + }); + + await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RECONCILING' } }); + await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]); + }); + it('dequeues only the invader decision while an UNIFICATION_WAIT suspension is active', async () => { const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } }); const world = existingWorld @@ -389,6 +554,37 @@ integration('database command queue', () => { message: { option: { action: 'raiseInvader', used: false } }, }, }); + await db.messageAction.create({ + data: { + messageId: message.id, + actionType: 'raiseInvader', + status: 'PENDING', + createdGameTick: 900n, + clockRevision: 31n, + deadlineGeneration: 7n, + }, + }); + const scoutMessage = await db.message.create({ + data: { + mailbox: 991_199, + type: 'private', + src: 7, + dest: 991_199, + time: new Date(), + validUntil: new Date('9999-12-31T00:00:00.000Z'), + message: { option: { action: 'scout', used: false } }, + }, + }); + await db.messageAction.create({ + data: { + messageId: scoutMessage.id, + actionType: 'scout', + status: 'PENDING', + createdGameTick: 900n, + clockRevision: 31n, + deadlineGeneration: 7n, + }, + }); await db.clockSuspension.create({ data: { id: 'integration-unification-wait', @@ -404,6 +600,7 @@ integration('database command queue', () => { }, }); const messageRequestId = 'integration:engine:unification-message'; + const scoutRequestId = 'integration:engine:suspended-scout-response'; const gameplayRequestId = 'integration:engine:unification-gameplay'; await db.inputEvent.createMany({ data: [ @@ -424,6 +621,23 @@ integration('database command queue', () => { response: true, }, }, + { + requestId: scoutRequestId, + target: 'ENGINE', + eventType: 'messageRespond', + actorUserId: 'user-991199', + acceptedGameTick: 900n, + acceptedClockRevision: 31n, + acceptedDeadlineGeneration: 7n, + payload: { + type: 'messageRespond', + requestId: scoutRequestId, + userId: 'user-991199', + generalId: 991_199, + messageId: scoutMessage.id, + response: true, + }, + }, { requestId: gameplayRequestId, target: 'ENGINE', @@ -451,10 +665,15 @@ integration('database command queue', () => { generalId: 991_199, messageId: message.id, response: true, + processingGameTick: 900, + requestedAtWall: expect.any(Date), }, ]); await expect( db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } }) ).resolves.toMatchObject({ status: 'PENDING' }); + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: scoutRequestId } })).resolves.toMatchObject({ + status: 'PENDING', + }); }); }); diff --git a/app/game-engine/test/inheritanceActionPersistence.integration.test.ts b/app/game-engine/test/inheritanceActionPersistence.integration.test.ts index 65719036..cf44f121 100644 --- a/app/game-engine/test/inheritanceActionPersistence.integration.test.ts +++ b/app/game-engine/test/inheritanceActionPersistence.integration.test.ts @@ -165,6 +165,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => { db = connector.prisma; disconnect = () => connector.disconnect(); await dropFailureConstraints(); + await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); @@ -198,6 +199,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => { await hooks?.close(); if (db) { await dropFailureConstraints(); + await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); @@ -284,7 +286,13 @@ integration('inheritance action PostgreSQL atomic persistence', () => { }, }); }; - const assertStored = async (point: number, spent: number, logCount: number, messageCount: number) => { + const assertStored = async ( + point: number, + spent: number, + logCount: number, + messageCount: number, + ledgerCount: number + ) => { await expect( db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId: actorUserId, key: 'previous' } }, @@ -299,6 +307,9 @@ integration('inheritance action PostgreSQL atomic persistence', () => { await expect( db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } }) ).resolves.toBe(messageCount); + await expect( + db.inheritanceLedger.count({ where: { requestId: { startsWith: requestPrefix } } }) + ).resolves.toBe(ledgerCount); }; const pointCommand = buildCommand('point', { @@ -315,7 +326,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => { await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`); expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 }); expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff'); - await assertStored(10_000, 17, 0, 0); + await assertStored(10_000, 17, 0, 0, 0); await expect( db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } }) ).resolves.toMatchObject({ @@ -324,7 +335,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => { }); await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`); await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 }); - await assertStored(9_800, 217, 1, 0); + await assertStored(9_800, 217, 1, 0, 1); const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId }); await createInputEvent(rankCommand); @@ -335,14 +346,14 @@ integration('inheritance action PostgreSQL atomic persistence', () => { `); await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`); expect(world.peekDirtyState().messages).toEqual([]); - await assertStored(9_800, 217, 1, 0); + await assertStored(9_800, 217, 1, 0, 1); await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`); await expect(execute(rankCommand)).resolves.toMatchObject({ ok: true, remainPoint: 8_800, ownerName: '레거시 소유자', }); - await assertStored(8_800, 1_217, 2, 2); + await assertStored(8_800, 1_217, 2, 2, 2); const currentLog = await db.inheritanceLog.findFirstOrThrow({ where: { userId: actorUserId }, @@ -358,10 +369,10 @@ integration('inheritance action PostgreSQL atomic persistence', () => { `); await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`); expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique'); - await assertStored(8_800, 1_217, 2, 2); + await assertStored(8_800, 1_217, 2, 2, 2); await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`); await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 }); - await assertStored(5_800, 4_217, 3, 2); + await assertStored(5_800, 4_217, 3, 2, 3); const freeStatCommand = buildCommand('free-stat', { action: 'resetStat', @@ -372,7 +383,21 @@ integration('inheritance action PostgreSQL atomic persistence', () => { }); await createInputEvent(freeStatCommand); await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 }); - await assertStored(5_800, 4_217, 5, 2); + await assertStored(5_800, 4_217, 5, 2, 4); + + const ledgers = await db.inheritanceLedger.findMany({ + where: { requestId: { startsWith: requestPrefix } }, + orderBy: { id: 'asc' }, + }); + expect(ledgers.map(({ action, cost, status }) => ({ action, cost, status }))).toEqual([ + { action: 'buyHiddenBuff', cost: 200, status: 'APPLIED' }, + { action: 'checkOwner', cost: 1_000, status: 'APPLIED' }, + { action: 'buyRandomUnique', cost: 3_000, status: 'APPLIED' }, + { action: 'resetStat', cost: 0, status: 'APPLIED' }, + ]); + expect(ledgers.every((row) => row.consumedAtWall instanceof Date && row.createdAtWall instanceof Date)).toBe( + true + ); const messages = await db.message.findMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } }, diff --git a/app/game-engine/test/inputEventAtomicity.test.ts b/app/game-engine/test/inputEventAtomicity.test.ts index de4e9b5c..4e6ee491 100644 --- a/app/game-engine/test/inputEventAtomicity.test.ts +++ b/app/game-engine/test/inputEventAtomicity.test.ts @@ -133,6 +133,7 @@ describe('input event atomicity', () => { ok: true, auctionId: 3, closeAt: '2026-01-01T00:10:00.000Z', + closeTick: 3_600_000, }; let resolveResponse: (() => void) | undefined; const responded = new Promise((resolve) => { diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 11a337d4..ffa7d19c 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -806,6 +806,8 @@ describeDb('scenario database seed', () => { amount: 1, eventId: marker, eventAt: new Date('2033-01-01T00:00:00.000Z'), + occurredGameTick: 0n, + requestedAtWall: new Date('2033-01-01T00:00:00.000Z'), }, }); const bettingId = 990_731; diff --git a/app/game-engine/test/selectPoolReservation.test.ts b/app/game-engine/test/selectPoolReservation.test.ts index 2883a82d..65835e7d 100644 --- a/app/game-engine/test/selectPoolReservation.test.ts +++ b/app/game-engine/test/selectPoolReservation.test.ts @@ -224,7 +224,7 @@ describe('selection-pool reservation command state', () => { const rows = buildRows(); const world = buildWorld(rows); const db = buildDb(rows); - const reserve = (userId: string, acceptedGameTick: number) => + const reserve = (userId: string, processingGameTick: number) => reserveSelectionPool({ db: db as never, world, @@ -232,7 +232,7 @@ describe('selection-pool reservation command state', () => { userId, seedOwnerIdentity: userId, now: acceptedAt, - acceptedGameTick, + processingGameTick, }); const first = await reserve('first-user', 0); @@ -267,7 +267,7 @@ describe('selection-pool reservation command state', () => { rows[1]!.reservedUntilTick = 0n; const world = buildWorld(rows); const db = buildDb(rows); - const reserve = (userId: string, acceptedGameTick: number) => + const reserve = (userId: string, processingGameTick: number) => reserveSelectionPool({ db: db as never, world, @@ -275,7 +275,7 @@ describe('selection-pool reservation command state', () => { userId, seedOwnerIdentity: userId, now: acceptedAt, - acceptedGameTick, + processingGameTick, }); const first = await reserve('first-user', 0); diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts index f1a30043..d809023d 100644 --- a/app/game-engine/test/voteReward.test.ts +++ b/app/game-engine/test/voteReward.test.ts @@ -12,7 +12,6 @@ import { } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js'; @@ -81,38 +80,12 @@ const buildDefaultUniquePoolSnapshot = (general: TurnGeneral): TurnWorldSnapshot }); describe('voteReward command', () => { - it('keeps the wall-time fallback open at exact deadline equality', () => { + it('fails closed when a GAME_TIME poll lost its authoritative end tick', () => { const deadline = new Date('0180-01-01T00:00:00.000Z'); - expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, deadline, 0)).toBe(false); - expect( - hasVotePollDeadlinePassed( - { endAt: deadline, endTick: null, closedAt: null }, - new Date(deadline.getTime() + 1), - 0 - ) - ).toBe(true); - }); - - it('preserves the server-accepted game tick through durable command normalization', () => { - expect( - normalizeTurnDaemonCommand({ - requestId: 'vote-accepted-tick', - sentAt: '2026-08-23T00:00:00.000Z', - command: { - type: 'voteReward', - userId: 'user-1', - voteId: 1, - generalId: 1, - selection: [0], - acceptedGameTick: 100, - }, - }) - ).toMatchObject({ - type: 'voteReward', - requestId: 'vote-accepted-tick', - acceptedGameTick: 100, - }); + expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, 0)).toBe(true); + expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 0)).toBe(false); + expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 1)).toBe(true); }); it('applies gold, unique item, logs, and idempotency', async () => { @@ -290,9 +263,7 @@ describe('voteReward command', () => { voteId: 1, generalId: 1, selection: [0], - // Ref accepts the request at exact equality. Engine processing may - // occur after the logical clock has advanced beyond the deadline. - acceptedGameTick: 0, + processingGameTick: 0, }; const writerWindowStart = Date.now(); @@ -418,29 +389,26 @@ describe('voteReward command', () => { { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } ); const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld }); - const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command; - const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, { - db: { - ...actorBindingDb(), - $queryRaw: async (query: { strings: readonly string[] }) => - query.strings.join(' ').includes('SELECT options') - ? [ - { - options: ['찬성'], - multipleOptions: 1, - endAt: null, - endTick: 0n, - closedAt: null, - }, - ] - : [], - } as any, - }); - expect(legacyLateResult).toMatchObject({ - type: 'voteReward', - ok: false, - reason: '설문조사가 종료되었습니다.', - }); + const { processingGameTick: _processingGameTick, ...missingBoundaryCommand } = command; + await expect( + legacyLateHandler.handle(missingBoundaryCommand, { + db: { + ...actorBindingDb(), + $queryRaw: async (query: { strings: readonly string[] }) => + query.strings.join(' ').includes('SELECT options') + ? [ + { + options: ['찬성'], + multipleOptions: 1, + endAt: null, + endTick: 0n, + closedAt: null, + }, + ] + : [], + } as any, + }) + ).rejects.toThrow('authoritative daemon processing game tick'); }); it.each([ @@ -501,8 +469,8 @@ describe('voteReward command', () => { voteId, generalId: 1, selection: [0], - acceptedGameTick, - }, + processingGameTick: acceptedGameTick, + } as any, { db: commandDb as any } ); @@ -602,7 +570,8 @@ describe('voteReward command', () => { voteId: 1, generalId: 1, selection: [0], - }, + processingGameTick: 0, + } as any, { db: commandDb as any } ); diff --git a/app/game-frontend/src/utils/gameServerActivity.ts b/app/game-frontend/src/utils/gameServerActivity.ts index cebcb47e..8069b960 100644 --- a/app/game-frontend/src/utils/gameServerActivity.ts +++ b/app/game-frontend/src/utils/gameServerActivity.ts @@ -12,7 +12,7 @@ export const createGameServerActivityTracker = (): GameServerActivityTracker => return { lastContactAt: readonly(lastContactAt), - markContact(contactAt = Date.now()) { + markContact(contactAt = performance.now()) { if (!Number.isFinite(contactAt)) return; lastContactAt.value = contactAt; }, @@ -21,7 +21,7 @@ export const createGameServerActivityTracker = (): GameServerActivityTracker => export const isRecentGameServerActivity = ( lastContactAt: number | null, - now = Date.now(), + now = performance.now(), freshnessMs = GAME_SERVER_ACTIVITY_FRESHNESS_MS ): boolean => lastContactAt !== null && @@ -31,4 +31,4 @@ export const isRecentGameServerActivity = ( export const gameServerActivity = createGameServerActivityTracker(); -export const markGameServerContact = (contactAt = Date.now()) => gameServerActivity.markContact(contactAt); +export const markGameServerContact = (contactAt = performance.now()) => gameServerActivity.markContact(contactAt); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index bb764e44..03a04c53 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1260,7 +1260,7 @@ export const adminRouter = router({ if (!canReadProfile(adminAuth, initialOperation.profileName)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' }); } - const deadline = Date.now() + input.timeoutMs; + const deadline = performance.now() + input.timeoutMs; while (true) { const [operation, entries] = await Promise.all([ ctx.profiles.getOperation(input.id), @@ -1270,7 +1270,7 @@ export const adminRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' }); } const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status); - if (entries.length || terminal || Date.now() >= deadline) { + if (entries.length || terminal || performance.now() >= deadline) { return { operation, entries, @@ -1942,7 +1942,7 @@ export const adminRouter = router({ }) ) .query(async ({ ctx, input }) => { - const deadline = Date.now() + input.timeoutMs; + const deadline = performance.now() + input.timeoutMs; while (true) { const [operation, entries] = await Promise.all([ ctx.releases.getOperation(input.id), @@ -1952,7 +1952,7 @@ export const adminRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Gateway release operation not found.' }); } const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status); - if (entries.length || terminal || Date.now() >= deadline) { + if (entries.length || terminal || performance.now() >= deadline) { return { operation, entries, @@ -2519,8 +2519,8 @@ export const adminRouter = router({ reason: input.reason, requestedBy: adminAuth.user.id, }); - const deadline = Date.now() + 10 * 60_000; - while (Date.now() < deadline) { + const deadline = performance.now() + 10 * 60_000; + while (performance.now() < deadline) { await ctx.orchestrator.runOperationsNow(); const current = await ctx.profiles.getOperation(operation.id); if (current?.status === 'SUCCEEDED') { diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index cce492e4..e359d311 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -2995,7 +2995,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { profile: GatewayProfileRecord, assertLease?: () => Promise ): Promise { - const deadline = Date.now() + this.profileReadinessTimeoutMs; + const deadline = performance.now() + this.profileReadinessTimeoutMs; const definitions = buildProcessDefinitions(profile, this.processConfig); const expectedNames = Object.entries(definitions) .filter(([role]) => this.frontendServeMode === 'preview' || role !== 'frontend') @@ -3008,7 +3008,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { this.processConfig.frontendReadinessOrigin ?? 'http://caddy' ).toString() : `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`; - while (Date.now() < deadline) { + while (performance.now() < deadline) { await assertLease?.(); try { const [api, frontend, processes] = await Promise.all([ diff --git a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts index 5dbfdd5c..310977c3 100644 --- a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts +++ b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts @@ -242,13 +242,19 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat }); return mapOperation(row); }, - async claimNextOperation(now, lease) { + async claimNextOperation(_now, lease) { const row = await prisma.$transaction(async (tx) => { await tx.$queryRaw>` SELECT pg_advisory_xact_lock( hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0) )::text AS lock_result `; + const [{ now }] = await tx.$queryRaw>` + SELECT CURRENT_TIMESTAMP AS "now" + `; + if (!now) { + throw new Error('Database wall clock is unavailable while claiming a Gateway release operation.'); + } const runningProfileOperation = await tx.gatewayOperation.findFirst({ where: { status: 'RUNNING' }, select: { id: true }, @@ -326,13 +332,21 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat }); return row ? mapOperation(row) : null; }, - async renewOperationLease(id, ownerId, now, durationMs) { - const updated = await prisma.gatewayReleaseOperation.updateMany({ - where: { id, status: 'RUNNING', leaseOwner: ownerId }, - data: { - leaseUntil: new Date(now.getTime() + durationMs), - heartbeatAt: now, - }, + async renewOperationLease(id, ownerId, _now, durationMs) { + const updated = await prisma.$transaction(async (tx) => { + const [{ now }] = await tx.$queryRaw>` + SELECT CURRENT_TIMESTAMP AS "now" + `; + if (!now) { + throw new Error('Database wall clock is unavailable while renewing a Gateway release lease.'); + } + return tx.gatewayReleaseOperation.updateMany({ + where: { id, status: 'RUNNING', leaseOwner: ownerId }, + data: { + leaseUntil: new Date(now.getTime() + durationMs), + heartbeatAt: now, + }, + }); }); return updated.count === 1; }, diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index ce26acb3..36760808 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -691,7 +691,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat return mapOperation(row); }, async claimNextOperation( - now: Date, + _now: Date, lease?: { ownerId: string; durationMs: number } ): Promise { const row = await prisma.$transaction(async (tx) => { @@ -700,6 +700,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0) )::text AS lock_result `; + const [{ now }] = await tx.$queryRaw>` + SELECT CURRENT_TIMESTAMP AS "now" + `; + if (!now) { + throw new Error('Database wall clock is unavailable while claiming a Gateway operation.'); + } const runningRelease = await tx.gatewayReleaseOperation.findFirst({ where: { status: 'RUNNING' }, select: { id: true }, @@ -815,13 +821,21 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat }); return row ? mapOperation(row) : null; }, - async renewOperationLease(id: string, ownerId: string, now: Date, durationMs: number): Promise { - const renewed = await prisma.gatewayOperation.updateMany({ - where: { id, status: 'RUNNING', leaseOwner: ownerId }, - data: { - leaseUntil: new Date(now.getTime() + durationMs), - heartbeatAt: now, - }, + async renewOperationLease(id: string, ownerId: string, _now: Date, durationMs: number): Promise { + const renewed = await prisma.$transaction(async (tx) => { + const [{ now }] = await tx.$queryRaw>` + SELECT CURRENT_TIMESTAMP AS "now" + `; + if (!now) { + throw new Error('Database wall clock is unavailable while renewing a Gateway operation lease.'); + } + return tx.gatewayOperation.updateMany({ + where: { id, status: 'RUNNING', leaseOwner: ownerId }, + data: { + leaseUntil: new Date(now.getTime() + durationMs), + heartbeatAt: now, + }, + }); }); return renewed.count === 1; }, diff --git a/app/gateway-api/src/scenario/scenarioCatalog.ts b/app/gateway-api/src/scenario/scenarioCatalog.ts index 68307db8..f321a3ae 100644 --- a/app/gateway-api/src/scenario/scenarioCatalog.ts +++ b/app/gateway-api/src/scenario/scenarioCatalog.ts @@ -293,13 +293,13 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null }) } if (!gitRef) { const cached = previewCache.get(DEFAULT_CACHE_KEY); - if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) { + if (cached && performance.now() - cached.loadedAt < CACHE_TTL_MS) { return cached.data; } const ids = await listScenarioIds(); const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id))); previewCache.set(DEFAULT_CACHE_KEY, { - loadedAt: Date.now(), + loadedAt: performance.now(), data: previews, }); return previews; @@ -308,7 +308,7 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null }) const commitSha = await resolveGitCommitSha(gitRef); const cacheKey = commitSha; const cached = previewCache.get(cacheKey); - if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) { + if (cached && performance.now() - cached.loadedAt < CACHE_TTL_MS) { return cached.data; } const ids = await listScenarioIdsFromGit(commitSha); @@ -317,7 +317,7 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null }) previews.push(await buildScenarioPreviewFromGit(commitSha, id)); } previewCache.set(cacheKey, { - loadedAt: Date.now(), + loadedAt: performance.now(), data: previews, }); return previews; diff --git a/app/gateway-api/src/webPush/coordinator.ts b/app/gateway-api/src/webPush/coordinator.ts index dbea569a..11e16686 100644 --- a/app/gateway-api/src/webPush/coordinator.ts +++ b/app/gateway-api/src/webPush/coordinator.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; import { WEB_PUSH_EVENT_TYPES, @@ -409,10 +410,13 @@ export class WebPushCoordinator { `); if (rows.length === 0) return []; const ids = rows.map((row) => row.id); - await tx.webPushDelivery.updateMany({ - where: { id: { in: ids } }, - data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } }, - }); + await tx.$executeRaw(GatewayPrisma.sql` + UPDATE web_push_delivery + SET locked_at = CURRENT_TIMESTAMP, + lock_owner = ${this.owner}, + attempts = attempts + 1 + WHERE id IN (${GatewayPrisma.join(ids)}) + `); return tx.webPushDelivery.findMany({ where: { id: { in: ids }, lockOwner: this.owner }, include: { notification: true, subscription: true }, @@ -421,22 +425,31 @@ export class WebPushCoordinator { }); for (const delivery of claimed) { - if (delivery.subscription.expirationTime && delivery.subscription.expirationTime.getTime() <= Date.now()) { - await this.prisma.$transaction(async (tx) => { - await tx.webPushDelivery.updateMany({ - where: { id: delivery.id, lockOwner: this.owner }, - data: { - status: 'FAILED', - lockedAt: null, - lockOwner: null, - lastError: 'Push subscription expired.', - }, - }); - await tx.webPushSubscription.update({ - where: { id: delivery.subscriptionId }, - data: { disabledAt: new Date() }, - }); - }); + const expired = await this.prisma.$transaction(async (tx) => { + const count = await tx.$executeRaw(GatewayPrisma.sql` + UPDATE web_push_delivery AS delivery + SET status = 'FAILED'::"WebPushDeliveryStatus", + locked_at = NULL, + lock_owner = NULL, + last_error = 'Push subscription expired.' + FROM web_push_subscription AS subscription + WHERE delivery.id = ${delivery.id} + AND delivery.lock_owner = ${this.owner} + AND subscription.id = delivery.subscription_id + AND subscription.expiration_time IS NOT NULL + AND subscription.expiration_time <= CURRENT_TIMESTAMP + `); + if (count > 0) { + await tx.$executeRaw(GatewayPrisma.sql` + UPDATE web_push_subscription + SET disabled_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${delivery.subscriptionId} + `); + } + return count > 0; + }); + if (expired) { continue; } try { @@ -453,16 +466,15 @@ export class WebPushCoordinator { }), { TTL: 60 * 60 } ); - await this.prisma.webPushDelivery.updateMany({ - where: { id: delivery.id, lockOwner: this.owner }, - data: { - status: 'DELIVERED', - deliveredAt: new Date(), - lockedAt: null, - lockOwner: null, - lastError: null, - }, - }); + await this.prisma.$executeRaw(GatewayPrisma.sql` + UPDATE web_push_delivery + SET status = 'DELIVERED'::"WebPushDeliveryStatus", + delivered_at = CURRENT_TIMESTAMP, + locked_at = NULL, + lock_owner = NULL, + last_error = NULL + WHERE id = ${delivery.id} AND lock_owner = ${this.owner} + `); } catch (error) { const statusCode = typeof error === 'object' && error !== null && 'statusCode' in error @@ -478,28 +490,31 @@ export class WebPushCoordinator { const safeError = statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.'; await this.prisma.$transaction(async (tx) => { - await tx.webPushDelivery.updateMany({ - where: { id: delivery.id, lockOwner: this.owner }, - data: { - status: terminal || exhausted ? 'FAILED' : 'PENDING', - availableAt: new Date(Date.now() + delaySeconds * 1_000), - lockedAt: null, - lockOwner: null, - lastError: safeError, - }, - }); + const nextStatus = terminal || exhausted ? 'FAILED' : 'PENDING'; + await tx.$executeRaw(GatewayPrisma.sql` + UPDATE web_push_delivery + SET status = ${nextStatus}::"WebPushDeliveryStatus", + available_at = CURRENT_TIMESTAMP + + ${delaySeconds * 1_000} * INTERVAL '1 millisecond', + locked_at = NULL, + lock_owner = NULL, + last_error = ${safeError} + WHERE id = ${delivery.id} AND lock_owner = ${this.owner} + `); if (statusCode === 404 || statusCode === 410) { - await tx.webPushSubscription.update({ - where: { id: delivery.subscriptionId }, - data: { disabledAt: new Date() }, - }); + await tx.$executeRaw(GatewayPrisma.sql` + UPDATE web_push_subscription + SET disabled_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${delivery.subscriptionId} + `); } }); if (!terminal) this.onError(new Error(safeError)); } } - if (Date.now() >= this.nextPruneAt) { - this.nextPruneAt = Date.now() + 60_000; + if (performance.now() >= this.nextPruneAt) { + this.nextPruneAt = performance.now() + 60_000; await this.prisma.$transaction(async (tx) => { await tx.$executeRaw(GatewayPrisma.sql` WITH expired AS ( @@ -534,7 +549,7 @@ export class WebPushCoordinator { private run(): void { if (!this.configured || this.inFlight) return; - const now = Date.now(); + const now = performance.now(); const shouldReconcileProfiles = now >= this.nextProfileReconcileAt; if (shouldReconcileProfiles) this.nextProfileReconcileAt = now + 5_000; this.inFlight = (shouldReconcileProfiles ? this.reconcileProfiles() : Promise.resolve()) diff --git a/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts b/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts index afa21858..d0265124 100644 --- a/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts +++ b/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts @@ -41,13 +41,16 @@ describeDatabase('gateway release operation persistence', () => { ).rejects.toMatchObject({ code: 'P2002' }); const now = new Date('2030-01-01T00:00:00.000Z'); - await expect( - repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 }) - ).resolves.toMatchObject({ + const claimed = await repository.claimNextOperation(now, { + ownerId: 'controller-a', + durationMs: 1_000, + }); + expect(claimed).toMatchObject({ id: operation.id, attempts: 1, leaseOwner: 'controller-a', }); + expect(Date.parse(claimed?.leaseUntil ?? '')).toBeLessThan(Date.now() + 5_000); await expect(repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'a'.repeat(40))).resolves.toBe( true ); @@ -103,6 +106,11 @@ describeDatabase('gateway release operation persistence', () => { await repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 }); await repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'c'.repeat(40)); + await connector.prisma.$executeRaw` + UPDATE "gateway_release_operation" + SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second' + WHERE "id" = ${operation.id} + `; await expect( repository.claimNextOperation(new Date(now.getTime() + 1_001), { ownerId: 'controller-b', diff --git a/app/gateway-api/test/profileOperationLease.integration.test.ts b/app/gateway-api/test/profileOperationLease.integration.test.ts index 3f970e98..f707a0be 100644 --- a/app/gateway-api/test/profileOperationLease.integration.test.ts +++ b/app/gateway-api/test/profileOperationLease.integration.test.ts @@ -114,6 +114,11 @@ describeDatabase('gateway operation lease and profile serialization', () => { await expect( repository.claimNextOperation(beforeReset, { ownerId: 'worker-b', durationMs: 1_000 }) ).resolves.toBeNull(); + await connector.prisma.$executeRaw` + UPDATE "gateway_operation" + SET "scheduled_at" = CURRENT_TIMESTAMP - INTERVAL '1 second' + WHERE "id" = ${scheduledReset.id} + `; await expect( repository.claimNextOperation(scheduledAt, { ownerId: 'worker-b', durationMs: 1_000 }) ).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' }); @@ -168,13 +173,18 @@ describeDatabase('gateway operation lease and profile serialization', () => { sourceRef: 'b'.repeat(40), requestedBy: 'deploy-admin', }); + await connector.prisma.$executeRaw` + UPDATE "gateway_operation" + SET "scheduled_at" = CURRENT_TIMESTAMP - INTERVAL '1 second' + WHERE "id" = ${scheduledReset.id} + `; await expect( repository.claimNextOperation(scheduledAt, { ownerId: 'worker-a', durationMs: 1_000 }) ).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' }); await expect(repository.getOperation(interimDeploy.id)).resolves.toMatchObject({ status: 'CANCELLED', - completedAt: scheduledAt.toISOString(), + completedAt: expect.any(String), }); await expect(repository.listOperationLogs(interimDeploy.id)).resolves.toEqual([ expect.objectContaining({ @@ -405,6 +415,11 @@ describeDatabase('gateway operation lease and profile serialization', () => { durationMs: 1_000, }) ).resolves.toBeNull(); + await connector.prisma.$executeRaw` + UPDATE "gateway_operation" + SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second' + WHERE "id" = ${operation.id} + `; const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), { ownerId: 'worker-b', durationMs: 1_000, diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index b343fa08..fdd83a8a 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260825000000_add_bulk_release_batches', - gameSchemaHead: '20260903103000_add_input_event_clock_processing', + gameSchemaHead: '20260903140000_split_message_wall_and_game_time', }); }); diff --git a/app/gateway-frontend/src/composables/useAdminProfileNavigation.ts b/app/gateway-frontend/src/composables/useAdminProfileNavigation.ts index bd61c2f7..a23c7f53 100644 --- a/app/gateway-frontend/src/composables/useAdminProfileNavigation.ts +++ b/app/gateway-frontend/src/composables/useAdminProfileNavigation.ts @@ -14,13 +14,13 @@ let cachedAt = 0; let inFlight: Promise | undefined; export const loadAdminProfileNavigation = async (): Promise => { - if (cachedProfiles && Date.now() - cachedAt < 5_000) return cachedProfiles; + if (cachedProfiles && performance.now() - cachedAt < 5_000) return cachedProfiles; if (inFlight) return inFlight; inFlight = directTrpc.admin.profiles.listNavigation .query() .then((profiles) => { cachedProfiles = profiles as AdminProfileNavigationItem[]; - cachedAt = Date.now(); + cachedAt = performance.now(); return cachedProfiles; }) .finally(() => { diff --git a/app/release-controller/src/index.ts b/app/release-controller/src/index.ts index 235f5af2..7750dd9a 100644 --- a/app/release-controller/src/index.ts +++ b/app/release-controller/src/index.ts @@ -85,7 +85,7 @@ const main = async (): Promise => { process.once('SIGINT', () => void stop()); process.once('SIGTERM', () => void stop()); while (!stopping) { - const now = Date.now(); + const now = performance.now(); if (now >= nextWorkspaceCleanupAt) { nextWorkspaceCleanupAt = now + RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS; try { diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index 3e3f1503..f7f0f0d8 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -510,7 +510,7 @@ export class GatewayReleaseController { private async waitForReadiness(operationId: string): Promise { await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.'); - const deadline = Date.now() + this.config.readinessTimeoutMs; + const deadline = performance.now() + this.config.readinessTimeoutMs; const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`; const frontendUrl = this.config.frontendServeMode === 'static' @@ -522,7 +522,7 @@ export class GatewayReleaseController { const expectedNames = buildGatewayProcessDefinitions(this.config.workspaceRoot, this.config).map( (definition) => definition.name ); - while (Date.now() < deadline) { + while (performance.now() < deadline) { try { const [api, frontend] = await Promise.all([ this.fetchImpl(apiUrl), diff --git a/app/release-controller/src/selfUpgrade.ts b/app/release-controller/src/selfUpgrade.ts index 2a37e5b2..3fb7069d 100644 --- a/app/release-controller/src/selfUpgrade.ts +++ b/app/release-controller/src/selfUpgrade.ts @@ -92,8 +92,8 @@ export const upgradeReleaseController = async (options: { } try { await options.processManager.start(buildReleaseControllerDefinition(workspace.root, options.config)); - const deadline = Date.now() + (options.readinessTimeoutMs ?? options.config.readinessTimeoutMs); - while (Date.now() < deadline) { + const deadline = performance.now() + (options.readinessTimeoutMs ?? options.config.readinessTimeoutMs); + while (performance.now() < deadline) { const matching = (await options.processManager.list()).filter( (process) => process.name === CONTROLLER_PROCESS_NAME ); diff --git a/docs/architecture/game-clock-participants.json b/docs/architecture/game-clock-participants.json index b1b41e5d..3483fabc 100644 --- a/docs/architecture/game-clock-participants.json +++ b/docs/architecture/game-clock-participants.json @@ -16,13 +16,23 @@ "world_state.deadline_generation", "general.turn_tick", "general.recent_war_tick", + "general.meta.next_change_tick", "select_pool.reserved_until_tick", "select_npc_token.valid_until_tick", "select_npc_token.pick_more_from_tick", "message.time_tick", "message.valid_until_tick", + "message.occurred_game_tick", + "message_action.created_game_tick", + "message_action.expires_game_tick", + "message_action.resolved_game_tick", + "message_action.clock_revision", + "message_action.deadline_generation", + "inheritance_ledger.applied_clock_revision", + "inheritance_ledger.applied_deadline_generation", "auction.open_tick", "auction.close_tick", + "auction_bid.occurred_game_tick", "vote_poll.start_tick", "vote_poll.end_tick", "clock_suspension.source_revision", @@ -34,6 +44,43 @@ "clock_suspension.aligned_tick", "clock_projection_outbox.target_revision" ], + "wallTimeFields": [ + "input_event.created_at", + "input_event.processing_at", + "input_event.completed_at", + "input_event.lease_until", + "read_model_outbox.available_at", + "read_model_outbox.locked_at", + "read_model_outbox.delivered_at", + "web_push_outbox.available_at", + "web_push_outbox.locked_at", + "web_push_outbox.delivered_at", + "turn_daemon_lease.lease_until", + "turn_daemon_lease.heartbeat_at", + "message.created_at_wall", + "message.delete_until_wall", + "message.tombstoned_at_wall", + "message_read_state.updated_at", + "diplomacy_letter.date", + "auction_bid.requested_at_wall", + "auction_bid.created_at", + "auction.finalizing_at", + "auction.finished_at", + "inheritance_ledger.requested_at_wall", + "inheritance_ledger.consumed_at_wall", + "inheritance_ledger.created_at_wall", + "clock_suspension.cut_wall_at", + "clock_suspension.resume_wall_at" + ], + "excludedFromReconciliation": [ + "all WALL_TIME created_at and updated_at audit fields", + "normal message envelope and five-minute deletion lifecycle", + "account and inheritance receipt timestamps", + "traffic and general-access periods", + "notification and outbox delivery/retry timestamps", + "daemon, worker, editor, gateway, and release leases", + "board, authentication, account, audit, and operator timestamps" + ], "participants": [ { "key": "world-clock", @@ -56,6 +103,13 @@ "projectionFields": ["general.turn_time"], "owner": "game-engine/turn-daemon" }, + { + "key": "selection-reselection-deadline", + "policy": "SHIFT", + "authorityFields": ["general.meta.next_change_tick"], + "projectionFields": ["general.meta.next_change", "general.meta.nextChangeAt"], + "owner": "game-engine/select-pool" + }, { "key": "general-recent-war-occurrence", "policy": "KEEP", @@ -67,8 +121,15 @@ "key": "auction-open-occurrence", "policy": "KEEP", "authorityFields": ["auction.open_tick"], - "projectionFields": ["auction.created_at"], - "owner": "game-api/auction" + "projectionFields": [], + "owner": "game-engine/auction" + }, + { + "key": "auction-bid-occurrence", + "policy": "KEEP", + "authorityFields": ["auction_bid.occurred_game_tick"], + "projectionFields": ["auction_bid.event_at"], + "owner": "game-engine/auction" }, { "key": "auction-deadline", @@ -85,18 +146,37 @@ "owner": "game-api/auction-worker" }, { - "key": "message-occurrence", + "key": "message-action-occurrence", "policy": "KEEP", - "authorityFields": ["message.time_tick"], - "projectionFields": ["message.time"], - "owner": "game-engine/message" + "authorityFields": ["message_action.created_game_tick"], + "projectionFields": ["message.time", "message.time_tick"], + "rowScope": "messages with a message_action row only", + "owner": "game-engine/message-action" }, { - "key": "message-expiry", + "key": "message-action-expiry", "policy": "SHIFT", - "authorityFields": ["message.valid_until_tick"], - "projectionFields": ["message.valid_until"], - "owner": "game-engine/message" + "authorityFields": ["message_action.expires_game_tick"], + "projectionFields": ["message.valid_until", "message.valid_until_tick"], + "rowScope": "messages with a message_action row only", + "owner": "game-engine/message-action" + }, + { + "key": "message-action-clock-coordinate", + "policy": "REBUILD", + "authorityFields": ["message_action.clock_revision", "message_action.deadline_generation"], + "projectionFields": [], + "owner": "game-engine/message-action" + }, + { + "key": "inheritance-effect-coordinate", + "policy": "KEEP", + "authorityFields": [ + "inheritance_ledger.applied_clock_revision", + "inheritance_ledger.applied_deadline_generation" + ], + "projectionFields": [], + "owner": "game-engine/inheritance" }, { "key": "vote-start-occurrence", @@ -127,7 +207,7 @@ "owner": "game-engine/npc-selection" }, { - "key": "accepted-command-coordinate", + "key": "daemon-command-coordinate", "policy": "KEEP", "authorityFields": [ "input_event.accepted_game_tick", @@ -139,7 +219,7 @@ "input_event.processing_clock_revision", "input_event.processing_deadline_generation" ], - "owner": "game-api/input-event" + "owner": "game-engine/input-event-claim" }, { "key": "tournament-deadlines", @@ -220,6 +300,17 @@ "clock_projection_outbox.available_at", "clock_projection_outbox.locked_at", "clock_projection_outbox.applied_at", + "message.created_at_wall", + "message.delete_until_wall", + "message.tombstoned_at_wall", + "message_action.created_at_wall", + "message_action.updated_at_wall", + "auction.created_at", + "auction.updated_at", + "auction_bid.requested_at_wall", + "auction_bid.created_at", + "inheritance_ledger.requested_at_wall", + "inheritance_ledger.consumed_at_wall", "*.created_at", "*.updated_at" ] diff --git a/docs/architecture/game-clock-reconciliation.md b/docs/architecture/game-clock-reconciliation.md index 9cb093b8..35deb46c 100644 --- a/docs/architecture/game-clock-reconciliation.md +++ b/docs/architecture/game-clock-reconciliation.md @@ -3,11 +3,13 @@ ## Product contract Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000` -ticks. Wall time is an observation and operational-control input, never the -authority for gameplay ordering. A long suspension advances the observed game -coordinate to the resume wall instant without replaying skipped turns, monthly -events, RNG, auctions, or tournaments. Every movable future schedule is shifted -by the same exact tick delta, including the sub-turn remainder. +ticks. Wall time is separately authoritative for account, community, audit, +lease, retry, notification, and operational rules. It is never projected into a +game deadline. A long suspension advances the observed game coordinate to the +resume wall instant without replaying skipped turns, monthly events, RNG, +auctions, or tournaments. Every movable future GAME schedule is shifted by the +same exact tick delta, including the sub-turn remainder. WALL occurrences and +deadlines are outside that operation. The clock state is stored in `world_state`: @@ -50,6 +52,16 @@ The authoritative registry is architecture gate rejects a new tick/revision field that is absent from that inventory. +The participant set contains only GAME authority or its projections: world and +turn cursors, general turns/recent-war occurrences/reselection deadlines, +auction occurrences/deadlines, actionable-message occurrences/deadlines, +vote deadlines, selection/NPC windows, input-event game coordinates, +tournament Redis deadlines, and clock-operation metadata. A normal message's +`created_at_wall`/`delete_until_wall`, inheritance receipts, notification and +outbox retry timestamps, leases, and audit columns are explicitly excluded. +The former broad `message-expiry` meaning is split into +`message-action-expiry`; an envelope has no GAME lifetime. + ## Unification wait A unification month with an invader choice changes `RUNNING -> SUSPENDED` and @@ -109,9 +121,11 @@ turn-daemon fencing row -> Redis outbox projection ``` -The ordinary turn flush already validates phase, revision, and deadline -generation after taking this lock prefix. Clock operation participants will be -added without changing that prefix. +The ordinary turn flush and daemon command claim validate phase, revision, and +deadline generation after taking this lock prefix. WALL-only message/account +operations do not take this lock and remain available while suspended. Hybrid +operations commit their GAME effect only behind this fence; inheritance debit, +receipt, effect, and command success are one transaction. ## Opening invariant @@ -137,8 +151,17 @@ all present. Before that boundary, the loader and ordinary turn-flush fence both treat the row as legacy `MANUAL`; the first fenced flush installs the complete snapshot atomically instead of trusting the new column's `RUNNING` database default. Input-event acceptance does not use that compatibility fallback: an -API or worker may enqueue gameplay only after the authoritative clock is fully -initialized. +API or worker records only a DB-wall receipt, then the daemon establishes the +GAME coordinate while claiming under the authoritative fence. Rolling-upgrade +payload coordinates may be parsed and ignored, but never become rule authority. + +Migration `20260903140000_split_message_wall_and_game_time` separates message +envelopes from actions and adds explicit auction-bid occurrence/request facts, +inheritance receipts, and selection cooldown tick authority. Legacy projection +columns remain temporarily for old readers. A missing GAME tick fails closed; +it never changes the rule to WALL_TIME. A WALL rule likewise never derives an +authority tick. See [`time-domains.md`](./time-domains.md) for the complete +inventory and migration policy. No active participant remains `FORBID`. Tournament writes carry tick/revision/generation coordinates and are revision-fenced in Redis. diff --git a/docs/architecture/game-clock.md b/docs/architecture/game-clock.md index 09d57356..2245ec81 100644 --- a/docs/architecture/game-clock.md +++ b/docs/architecture/game-clock.md @@ -1,17 +1,20 @@ # 게임 시계 -게임 진행 시각은 `world_state.clock_tick`이 기준입니다. 벽시계는 daemon lease, -요청 timeout, 처리 budget과 같은 운영 제어에만 사용합니다. 장수 턴, 메시지 -유효기간, 투표, 경매와 대회 마감은 game tick 또는 그 tick에서 투영한 시각을 -사용합니다. +시간 규칙은 `GAME_TIME`, `WALL_TIME`, `MONOTONIC_ELAPSED_TIME`로 +나뉩니다. 게임 진행의 권위는 `world_state.clock_tick`, 영속 wall +판정의 권위는 PostgreSQL UTC 시계, 프로세스 내부 경과시간의 권위는 +monotonic clock입니다. 장수 턴·외교 효력·게임 경매·투표·대회는 GAME, +일반 메시지·계정·감사·lease·retry는 WALL입니다. 전체 필드별 계약은 +[`time-domains.md`](./time-domains.md)를 따릅니다. -한 턴은 항상 `36,000,000` tick입니다. `tick_seconds`가 바뀌면 현재 표시 +한 턴은 항상 `36,000,000` tick입니다. `tick_seconds`가 바뀌면 현재 GAME 표시 시각이 유지되도록 `clock_base_time`을 다시 계산하므로, 기존 장수 턴 순서와 -남은 턴 수가 보존됩니다. DateTime 필드는 이전 데이터와 화면을 위한 투영값이며 -tick 필드가 존재하면 tick이 우선합니다. +남은 턴 수가 보존됩니다. GAME 규칙의 DateTime은 화면/레거시 투영일 뿐이며 +tick이 반드시 authority입니다. WALL 규칙은 tick이 없어도 정상이며 +DateTime을 tick으로 변환해 판정하지 않습니다. 운영 중 턴 간격 변경은 Gateway의 내구성 런타임 작업으로만 수행합니다. 같은 -transaction에서 `world_state`, 장수·경매·메시지·설문 투영값과 checkpoint를 +transaction에서 `world_state`, 장수·경매·actionable message·설문 투영값과 checkpoint를 갱신하며 기존 역사/행동 로그의 `created_at`은 다시 쓰지 않습니다. 토너먼트의 Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다. @@ -29,6 +32,18 @@ Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다. 프로필 설치 시 선택한 모드는 DB에 저장됩니다. daemon의 환경변수는 로드한 모드를 명시적으로 덮어쓸 때만 사용해 주세요. +`SUSPENDED`와 `RECONCILING`에서는 `GameClock.nowTick()`이 wall anchor 이후의 +현실 경과시간을 더하지 않고 저장된 `clock_tick`을 그대로 반환합니다. 따라서 +24시간 동안 정지해도 actionable message, 토너먼트와 국가 베팅의 GAME deadline은 +줄지 않습니다. 일반 메시지 envelope와 5분 삭제 기한은 별도의 DB WALL_TIME이라 +같은 기간 계속 흐릅니다. 정지 전에 도착한 등용장도 envelope로 계속 수신·열람할 +수 있지만, 등용 수락 효과는 daemon GAME fence가 다시 열릴 때까지 적용되지 않습니다. + +이미 열린 토너먼트·국가 베팅에는 `SUSPENDED` 중에도 새 베팅을 제출할 수 있습니다. +이때 베팅 가능 여부는 frozen GAME coordinate로 판정하고, 재화 mutation은 현재 +phase/revision/generation을 다시 잠가 검증합니다. 단계 전환·마감·정산은 실행하지 +않으며, 원자적 reconciliation이 진행되는 `RECONCILING`에서는 새 베팅도 받지 않습니다. + ## 중단 후 재개 realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용합니다. @@ -45,21 +60,24 @@ realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용 장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행 시각으로 다시 고정합니다. -DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 migration +DB migration은 GAME 규칙의 기존 DateTime 투영에서 tick을 채웁니다. 새 설치와 migration 재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은 -무기한 호환값은 안전한 정수 범위를 넘을 수 있으므로 tick을 `NULL`로 두고 -DateTime fallback을 사용합니다. +무기한 호환값은 일반 메시지의 투영일 뿐입니다. actionable deadline은 +`expires_game_tick`, 일반 삭제 deadline은 `delete_until_wall`만이 authority이며 +NULL에 따라 다른 시계로 fallback하지 않습니다. ## 비동기 작업의 시계 경계 -게임 규칙의 수락·입찰·예약 시각은 logical game time을 사용하지만 daemon -queue의 `InputEvent.createdAt`, worker history retention과 timeout은 운영 -벽시계를 사용합니다. NPC 빙의 enqueue는 현재 logical game time을 event -payload의 `acceptedGameAt`에 고정합니다. queue에 들어갈 때 유효했던 token은 -처리 전 game tick이 진행해도 이 저장된 논리 수락 시각으로 다시 검증합니다. +외부 요청은 `InputEvent.createdAt` DB WALL_TIME으로 접수합니다. API payload가 +game tick을 미리 고정하지 않으며, daemon이 clock lock/fence 아래서 claim할 때 +`accepted_game_tick`/세대와 `processing_game_tick`/세대를 확정합니다. NPC, +선택, 투표, 경매, 유산 효과는 처리 tick으로 검증하며 stale revision은 +적용하지 않습니다. worker history retention·lease·retry는 DB WALL_TIME, +프로세스 대기 budget은 monotonic time입니다. -경매 입찰은 같은 logical tick에서 여러 번 일어날 수 있습니다. bid 표시 -시각은 같은 game time을 보존하고, optimistic 경합 판정은 임의 UUID의 +경매 입찰은 `requested_at_wall`로 현실 요청을, `occurred_game_tick`으로 GAME +사건을 별도 기록합니다. bid 표시 투영은 같은 game time을 보존하고, +optimistic 경합 판정은 임의 UUID의 사전순이 아니라 읽은 `latest_event_id`를 버전 토큰으로 사용합니다. worker 재시작 시 `OPEN`은 `close_tick` deadline에, 이미 마감 판정이 끝난 `FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시 diff --git a/docs/architecture/time-domains.md b/docs/architecture/time-domains.md new file mode 100644 index 00000000..85ae0a28 --- /dev/null +++ b/docs/architecture/time-domains.md @@ -0,0 +1,166 @@ +# Time-domain inventory + +This document is the authoritative classification of persistent timestamps, +deadlines, cooldowns, and process-local elapsed-time rules. Classification is +per rule, not per table. A feature may record both a wall occurrence and a game +effect; those are two facts, never one fallback clock. + +## Domain contract + +| Domain | Authority | Advances while suspended/reconciling | Reconciliation | +| ------------------------ | -------------------------------------------------------------- | ------------------------------------ | ----------------------------- | +| `GAME_TIME` | `world_state.clock_tick` under phase/revision/generation fence | no | `SHIFT`, `KEEP`, or `REBUILD` | +| `WALL_TIME` | PostgreSQL UTC `CURRENT_TIMESTAMP` for persistent decisions | yes | never | +| `MONOTONIC_ELAPSED_TIME` | `performance.now()` / monotonic process clock | process-local only | never persisted | + +`GameTick`, `ClockRevision`, `DeadlineGeneration`, `WallInstant`, and +`MonotonicDuration` name these meanings in new/refactored APIs. Existing +`createdAt`/`updatedAt` fields remain wall audit timestamps unless this inventory +explicitly calls them game projections. + +## Game database inventory + +`Pause` means whether the rule continues to age during `SUSPENDED` or +`RECONCILING`. `Projection` means a non-authoritative compatibility/display +representation. + +| Table / rule / field(s) | Current meaning | Domain and authority | Pause | Reconcile / projection | Decision and reason | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `world_state.clock_tick` | observed world coordinate | GAME, self-authoritative | stop | REBUILD | root of all game-time decisions | +| `world_state.last_turn_tick` | executed turn cursor | GAME | stop | SHIFT | execution order must preserve skipped-turn policy | +| `world_state.clock_revision`, `deadline_generation` | clock/future-deadline generations | GAME metadata | stop | REBUILD | stale commands/workers must fail their fence | +| `world_state.clock_base_time`, `clock_wall_anchor` | tick-to-date mapping and wall observation anchor | GAME projection metadata | n/a | REBUILD | not business wall deadlines | +| `world_state.updated_at` | row audit | WALL, DB UTC | advance | excluded | operational history is not shifted | +| `clock_suspension.source_revision`, `target_revision`, `cut_tick`, `catch_up_ticks`, `gap_ticks`, `shift_ticks`, `aligned_tick` | reconciliation plan/audit | GAME metadata | stop | KEEP | immutable clock operation facts | +| `clock_suspension.cut_wall_at`, `resume_wall_at`, `created_at`, `updated_at` | operator/runtime occurrence audit | WALL, DB UTC | advance | excluded | records when the real operation occurred | +| `clock_projection_outbox.target_revision` | target GAME generation | GAME metadata | stop | KEEP | projection fence | +| `clock_projection_outbox.available_at`, `locked_at`, `applied_at`, `created_at`, `updated_at` | retry/lease/audit | WALL, DB UTC | advance | excluded | worker control cannot pause with game time | +| `clock_reconciliation_participant` checksum/count/policy | immutable operation evidence | GAME operation metadata | n/a | KEEP | evidence, not a deadline | +| `input_event.accepted_game_tick`, `accepted_clock_revision`, `accepted_deadline_generation` | daemon-claim boundary | GAME metadata | stop | KEEP across matching revision; rebase pending legacy rows only | API does not pre-stamp these; daemon owns acceptance | +| `input_event.processing_game_tick`, `processing_clock_revision`, `processing_deadline_generation` | actual mutation boundary | GAME metadata | stop | KEEP | effect validation/RNG uses this coordinate | +| `input_event.created_at`, `processing_at`, `completed_at`, `lease_until` | request receipt, processing audit, lease | WALL, DB UTC | advance | excluded | external occurrence and worker lease | +| `read_model_outbox.*_at`, `web_push_outbox.*_at` | availability, claim, delivery, audit | WALL, DB UTC | advance | excluded | retry and notification delivery are operational | +| `turn_daemon_lease.lease_until`, `heartbeat_at` | daemon liveness | WALL, DB UTC | advance | excluded | a paused game must still lose a dead daemon lease | +| `general.turn_tick` | next general turn | GAME | stop | SHIFT; `turn_time` projection | determines engine order | +| `general.recent_war_tick` | past battle occurrence | GAME | stop | KEEP; `recent_war_time` projection | historical event does not move | +| `general.meta.next_change_tick` | N-turn reselection cooldown | GAME | stop | SHIFT; `next_change`/`nextChangeAt` projections | expressed in turns; missing tick fails closed | +| `general.created_at`, `updated_at` | entity audit | WALL, DB UTC | advance | excluded | no gameplay deadline meaning | +| `select_pool.reserved_until_tick` | selection reservation deadline | GAME | stop | SHIFT; `reserved_until` projection | reservation is measured in game turns | +| `select_npc_token.valid_until_tick`, `pick_more_from_tick` | NPC selection windows | GAME | stop | SHIFT; DateTime projections | token is a game selection schedule; missing ticks fail closed | +| `general_access_log.last_refresh`, `last_action_at`; `general_access_batch.created_at`; `traffic_period.started_at`, `last_refresh`; `traffic_period_general.last_refresh` | traffic/access accounting | WALL, DB UTC | advance | excluded | community/operations usage, not world progression | +| `message.created_at_wall`, `delete_until_wall`, `tombstoned_at_wall` | envelope send/delete lifecycle | WALL, DB UTC | advance | excluded | normal messages work while the game is paused; deletion is real five minutes | +| `message.occurred_game_tick` | optional game context | GAME occurrence | stop | KEEP | context only, never deletion authority | +| `message.time`, `time_tick`, `valid_until`, `valid_until_tick` | rolling compatibility projections | projection only | n/a | recompute only for `message_action`; general-envelope values never decide lifecycle | old columns are not fallback authority | +| `message_action.created_game_tick`, `resolved_game_tick` | action occurrence/resolution | GAME occurrence | stop | KEEP | actionable message lifecycle is separate from envelope | +| `message_action.expires_game_tick` | proposal response deadline | GAME | stop | SHIFT | remaining game duration survives pause | +| `message_action.clock_revision`, `deadline_generation` | response fence | GAME metadata | stop | REBUILD | stale responses are rejected | +| `message_action.created_at_wall`, `updated_at_wall`; `message_read_state.updated_at` | audit/read occurrence | WALL, DB UTC | advance | excluded | community UX state | +| `diplomacy_letter.date` | document authored/sent time | WALL, DB UTC | advance | excluded | game effect dates live in diplomacy/action state, not the document timestamp | +| diplomacy war/nonaggression start/end month data | diplomatic effect schedule | GAME calendar | stop | handled by engine schedule | affects world turns and war validity | +| `inheritance_point.updated_at`, `inheritance_log.created_at`, `inheritance_result.created_at`, baseline/user-state audit fields | account ledger/result audit | WALL, DB UTC | advance | excluded | account/external-currency history | +| `inheritance_ledger.requested_at_wall`, `consumed_at_wall`, `created_at_wall` | direct purchase receipt | WALL, DB UTC | advance | excluded | real request/debit receipt | +| `inheritance_ledger.applied_clock_revision`, `applied_deadline_generation` | game-effect fence metadata | GAME metadata | stop | KEEP | no `applied_game_tick`: current direct effects are timeless immediate state changes | +| inheritance command `input_event` | durable effect state/idempotency | WALL receipt + GAME processing fence | mixed, separated | only GAME coordinate participates | one transaction commits debit, receipt, effect, and command success; failure leaves durable input event and no debit | +| `auction.open_tick`, `auction_bid.occurred_game_tick` | open/bid game occurrence | GAME | stop | KEEP; bid `event_at` is projection | event order/RNG/replay context | +| `auction.close_tick` | in-world close deadline | GAME | stop | SHIFT; `close_at` projection | authoritative worker/finalizer deadline; missing tick fails closed | +| `auction_bid.requested_at_wall`, `created_at`; `auction.finalizing_at`, `finished_at`, `created_at`, `updated_at` | request/processing/audit | WALL, DB UTC | advance | excluded | real action and recovery history | +| `auction.latest_event_at` | optimistic compatibility projection of latest game event | GAME projection | stop | follows authoritative event tick | never used as wall deadline | +| `vote_poll.start_tick`, `end_tick` | poll occurrence/deadline | GAME | stop | KEEP/SHIFT; `start_at`/`end_at` projections | poll is an in-world survey; missing deadline tick fails closed | +| `vote_poll.closed_at`, `created_at`, `updated_at`; `vote.created_at`; `vote_comment.created_at` | closure/user/audit occurrence | WALL, DB UTC | advance | excluded | closure receipt and community content history | +| tournament `nextTick`, `bettingCloseTick` in Redis | stage/betting deadlines | GAME | stop | REBUILD; `nextAt`/`bettingCloseAt` projections | stages advance with the world; legacy date-only state fails closed | +| nation betting open/close year-month and tournament phase | in-world availability | GAME calendar/tick | stop | engine/Redis participant | tied to tournament turns | +| tournament/nation bet submission | user WALL request + effect at the current frozen GAME coordinate | WALL + GAME, separated | submission is allowed during `SUSPENDED`; GAME deadline does not age | receipt excluded; GAME availability/fence retained | pausing stage progress must not close an already-open betting window | +| `nation_betting.*_at`, `nation_bet.*_at` | user/audit occurrence | WALL, DB UTC | advance | excluded | receipts, not close authority | +| `game_history.date`, old-general `turntime`, archived projected dates | archived game-calendar projection | GAME historical display | stop | KEEP, never shifted | immutable archive/replay record | +| archive/entity `created_at`, cancellation `opened_at`/`cancelled_at`, unification `completed_at` | operation/archive audit | WALL, DB UTC | advance | excluded | real creation/completion facts | +| `general_turn_revision.lease_expires_at`, `nation_turn_revision.lease_expires_at` and audit timestamps | edit lease/revision audit | WALL, DB UTC | advance | excluded | editor concurrency timeout | +| board post/comment, log/error/event, legacy migration timestamps | content/audit/migration history | WALL, DB UTC | advance | excluded | community and operational evidence | + +The year-9999 message sentinel remains a legacy projection only. +`MAX_SAFE_GAME_TICK` is the separate GAME-domain infinite sentinel. Neither is +converted into or used as the other domain's ordinary deadline. + +## Gateway database inventory + +Gateway has no gameplay clock authority. Every Gateway `DateTime` is WALL_TIME: + +- `app_user`: identity/session/icon/terms/privacy/Kakao/grace/deletion/login and + `created_at`/`updated_at` fields. +- access grants, retired identities, admin audits, user icons, legacy member + logs, and migration timestamps. +- profile lifecycle `preopen_at`, `open_at`, `scheduled_start_at`, build request, + start/completion/last-used, and row audit timestamps. These are real control + plane schedules; they do not replace a profile's `world_state.clock_tick`. +- subscriptions/preferences/receipts/notifications and web-push delivery + `available_at`, `locked_at`, `delivered_at`, expiration and audit fields. +- runtime actions, operations, releases, and bulk releases: schedule, start, + completion, retry, lease, heartbeat, successful and audit timestamps. + +Competitive Gateway operation/release claim and lease renewal decisions read +PostgreSQL `CURRENT_TIMESTAMP` inside the persistence transaction; the caller's +poll timestamp is not authoritative. A Gateway PREOPEN wall schedule is an operational request; once +the game exists, gameplay schedules use the game database tick. + +## Process-local monotonic inventory + +The following are `MONOTONIC_ELAPSED_TIME` and are never persisted: daemon/RPC +wait budgets, turn processing budgets, worker poll/resync intervals, lock +acquisition waits, readiness loops, short-lived cache TTLs, latency metrics, and +test wait loops. Production implementations use `performance.now()` where an +elapsed duration is measured. `Date.now()`/`new Date()` remains valid only when +creating or formatting a WALL occurrence, calculating a non-competitive auth +TTL for an external protocol, or providing an explicit test clock. + +## API phase policy + +| Operation | During `SUSPENDED` / `RECONCILING` | Fence | +| -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| normal public/private/nation message send/read/delete, including receiving/reading an existing recruitment letter envelope | allowed | WALL DB transaction only; actionable deadline remains frozen GAME state | +| notification/account/inheritance history/audit reads | allowed | WALL | +| actionable message response | rejected/queued except the explicitly authorized unification response | daemon clock phase/revision/generation | +| tournament/nation bet submission while its GAME window is open | allowed in `SUSPENDED`; rejected in `RECONCILING` | frozen GAME deadline + phase/revision/generation fence | +| tournament stage transition/close/settlement and nation-bet close/settlement | not applied | daemon GAME fence | +| turn, reservation, war/diplomacy effect, auction, vote, other tournament mutation | not applied | daemon GAME fence | +| direct inheritance state mutation | rejected if the daemon GAME fence cannot commit | atomic input-event + revision/generation fence | + +There is currently no account-only inheritance purchase endpoint. Direct +inheritance commands use policy 1 (no debit if game mutation cannot commit), not +an ambiguous partially-applied state. Their `InputEvent.requestId` is the durable +idempotency/effect state; `InheritanceLedger.requestId` proves the one successful +receipt. + +## `loadCurrentGameTime` call-site audit + +All production call sites were reviewed. The remaining uses are GAME-only: + +- `messages/store`: create/read/invalidate the separate `message_action`; normal + envelope creation, display, read state, and deletion do not load game time. +- `messages/diplomaticResponse`: game-effect/log calendar context after an + actionable response fence; the letter's authored date is DB WALL_TIME. +- auction `open`, `scheduler`, `worker`, and router: open/close tick projection, + due evaluation, and fence context; bid receipt time is separate WALL_TIME. +- vote router, tournament router/worker: GAME poll/stage deadlines and Redis + projection fences. +- troop/general/join selection routers: current world turn/schedule context; + the NPC reservation mutation holds the clock advisory lock and `world_state` + row fence before reading it. +- lobby: display-only projected server game time and phase. + +No inheritance receipt, ordinary message timestamp/delete rule, audit log, +lease, outbox retry, API timeout, or worker budget calls this helper. + +Ordinary message send/read-state/delete mutations retain their durable API +`InputEvent` transaction and read-model journal, but use the WALL-only input +boundary. That boundary deliberately does not acquire the game clock advisory +lock, so a reconciliation transaction cannot unnecessarily serialize community +messaging. Actionable responses continue to use the GAME-fenced boundary. + +## Migration boundary + +Migration `20260903140000_split_message_wall_and_game_time` adds and backfills +the explicit message, action, auction-bid, inheritance receipt, and selection +cooldown authorities. It never extends an already-expired message delete window. +Old projection columns remain during rolling deployment, but new code never +chooses a clock by NULL fallback: GAME rules require their tick; WALL rules use +their wall column. The disposable migration verifier covers populated upgrade, +indexes/constraints, replay safety, and a second no-op deploy. diff --git a/packages/common/src/time/GameClock.ts b/packages/common/src/time/GameClock.ts index a52eb7bd..fa068be2 100644 --- a/packages/common/src/time/GameClock.ts +++ b/packages/common/src/time/GameClock.ts @@ -9,13 +9,18 @@ declare const gameTickBrand: unique symbol; declare const observedGameInstantBrand: unique symbol; declare const scheduleInstantBrand: unique symbol; declare const clockRevisionBrand: unique symbol; +declare const deadlineGenerationBrand: unique symbol; declare const wallInstantBrand: unique symbol; +declare const monotonicDurationBrand: unique symbol; export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' }; export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' }; export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' }; export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' }; +export type DeadlineGeneration = number & { readonly [deadlineGenerationBrand]: 'DeadlineGeneration' }; export type WallInstant = Date & { readonly [wallInstantBrand]: 'WallInstant' }; +/** Process-local elapsed milliseconds; never persist this value as a business timestamp. */ +export type MonotonicDuration = number & { readonly [monotonicDurationBrand]: 'MonotonicDuration' }; export interface ClockAlignmentPlan { policy: ClockAlignmentPolicy; @@ -59,6 +64,13 @@ export const asClockRevision = (revision: number): ClockRevision => { return revision as ClockRevision; }; +export const asDeadlineGeneration = (generation: number): DeadlineGeneration => { + if (!Number.isSafeInteger(generation) || generation < 1) { + throw new Error(`Deadline generation must be a positive safe integer: ${generation}`); + } + return generation as DeadlineGeneration; +}; + export const asWallInstant = (instant: Date): WallInstant => { if (Number.isNaN(instant.getTime())) { throw new Error('Wall instant must be a valid date.'); @@ -66,6 +78,13 @@ export const asWallInstant = (instant: Date): WallInstant => { return new Date(instant.getTime()) as WallInstant; }; +export const asMonotonicDuration = (milliseconds: number): MonotonicDuration => { + if (!Number.isFinite(milliseconds) || milliseconds < 0) { + throw new Error(`Monotonic duration must be a non-negative finite number: ${milliseconds}`); + } + return milliseconds as MonotonicDuration; +}; + export const inferClockPhase = (mode: GameClockMode): GameClockPhase => (mode === 'manual' ? 'MANUAL' : 'RUNNING'); const GAME_CLOCK_PHASES: readonly GameClockPhase[] = [ @@ -136,8 +155,7 @@ const buildAlignmentPlan = (input: { const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000; const gapTicks = asGameTick( requireSafeTick( - wholeSeconds * input.ticksPerSecond + - Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000) + wholeSeconds * input.ticksPerSecond + Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000) ) ); const catchUpTicks = asGameTick(input.catchUpTicks ?? 0); @@ -250,7 +268,13 @@ export class GameClock { } nowTick(wallNow: Date): number { - if (this.mode === 'manual' || this.phase === 'MANUAL' || this.phase === 'COMPLETED') { + if ( + this.mode === 'manual' || + this.phase === 'MANUAL' || + this.phase === 'SUSPENDED' || + this.phase === 'RECONCILING' || + this.phase === 'COMPLETED' + ) { return this.tick; } const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow); diff --git a/packages/common/src/tournament/sourceRevision.ts b/packages/common/src/tournament/sourceRevision.ts index d4ab9d6f..8900edc5 100644 --- a/packages/common/src/tournament/sourceRevision.ts +++ b/packages/common/src/tournament/sourceRevision.ts @@ -24,7 +24,7 @@ export interface TournamentClockFence { phaseKey: string; revision: number; deadlineGeneration: number; - phase: 'RUNNING'; + phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED'; } const WRITE_TOURNAMENT_PROJECTION_SCRIPT = ` diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index a8fb7ae5..575607ef 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -190,7 +190,7 @@ export type TurnDaemonCommand = requestId?: string; auctionId: number; expectedCloseAt?: string; - expectedCloseTick?: number; + expectedCloseTick: number; } | { type: 'auctionOpen'; @@ -260,7 +260,6 @@ export type TurnDaemonCommand = voteId: number; generalId: number; selection: number[]; - acceptedGameTick?: number; } | { type: 'setNationSetting'; @@ -386,15 +385,12 @@ export type TurnDaemonCommand = ownerLegacyPenalty?: Record; generalId: number; tokenNonce: number; - acceptedGameAt?: string; } | { type: 'selectPoolReserve'; requestId?: string; userId: string; seedOwnerIdentity: string | number; - acceptedGameAt: string; - acceptedGameTick?: number; } | { type: 'selectPoolCreate'; @@ -407,8 +403,6 @@ export type TurnDaemonCommand = ownerPicture?: string; ownerImageServer?: number; ownerIconRevision?: string; - acceptedGameAt?: string; - acceptedGameTick?: number; } | { type: 'selectPoolReselect'; @@ -416,8 +410,6 @@ export type TurnDaemonCommand = userId: string; ownerDisplayName: string; uniqueName: string; - acceptedGameAt?: string; - acceptedGameTick?: number; } | { type: 'auctionBid'; @@ -426,7 +418,6 @@ export type TurnDaemonCommand = auctionId: number; generalId: number; amount: number; - acceptedGameTick?: number; tryExtendCloseDate?: boolean; }; @@ -505,6 +496,7 @@ export type TurnDaemonCommandResult = ok: true; auctionId: number; closeAt: string; + closeTick: number; } | { type: 'auctionOpen'; @@ -835,6 +827,7 @@ export type TurnDaemonCommandResult = ok: true; auctionId: number; closeAt: string; + closeTick: number; } | { type: 'auctionBid'; diff --git a/packages/common/test/gameClock.test.ts b/packages/common/test/gameClock.test.ts index 6672cae8..cd6ae15a 100644 --- a/packages/common/test/gameClock.test.ts +++ b/packages/common/test/gameClock.test.ts @@ -13,6 +13,24 @@ import { } from '../src/time/GameClock.js'; describe('GameClock', () => { + it.each(['SUSPENDED', 'RECONCILING'] as const)( + 'keeps the authoritative tick frozen across 24 wall hours while %s', + (phase) => { + const clock = new GameClock({ + baseTime: new Date('0200-01-01T00:00:00.000Z'), + tick: 12_345, + mode: 'realtime', + wallAnchor: new Date('2026-09-03T15:00:00.000Z'), + turnSeconds: 600, + phase, + revision: 7, + }); + + expect(clock.nowTick(new Date('2026-09-04T15:00:00.000Z'))).toBe(12_345); + expect(clock.now(new Date('2026-09-04T15:00:00.000Z'))).toEqual(clock.tickToDate(12_345)); + } + ); + const baseTime = new Date('2042-01-01T00:00:00.000Z'); it('projects the fixed Ref turn tick and ignores wall time in manual mode', () => { diff --git a/packages/infra/package.json b/packages/infra/package.json index f5d5bee4..9697956d 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -22,6 +22,7 @@ "verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh", "verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh", "verify:migration:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh", + "verify:migration:time-domains": "sh scripts/verify-time-domain-migration.sh", "coverage:activate:game": "node scripts/activate-read-model-coverage.mjs", "prisma:db:push:game": "prisma db push --schema prisma/game.prisma", "prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma" diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 67b05cea..748fe4db 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -433,20 +433,50 @@ model MessageReadState { } model Message { - id Int @id @default(autoincrement()) - mailbox Int - type String - src Int - dest Int - time DateTime - timeTick BigInt? @map("time_tick") - validUntil DateTime @map("valid_until") - validUntilTick BigInt? @map("valid_until_tick") - message Json + id Int @id @default(autoincrement()) + mailbox Int + type String + src Int + dest Int + /// Legacy game-date projection. Never use this as the wall occurrence authority. + time DateTime + /// Legacy game-date projection coordinate. New rules use occurredGameTick or MessageAction. + timeTick BigInt? @map("time_tick") + /// Legacy envelope/action visibility projection retained for rolling compatibility. + validUntil DateTime @map("valid_until") + /// Legacy action deadline projection retained for rolling compatibility. + validUntilTick BigInt? @map("valid_until_tick") + createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3) + deleteUntilWall DateTime @default(dbgenerated("((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '5 minutes')")) @map("delete_until_wall") @db.Timestamp(3) + tombstonedAtWall DateTime? @map("tombstoned_at_wall") @db.Timestamp(3) + occurredGameTick BigInt? @map("occurred_game_tick") + message Json + action MessageAction? + + @@index([mailbox, type, id]) + @@index([deleteUntilWall]) @@map("message") } +model MessageAction { + messageId Int @id @map("message_id") + actionType String @map("action_type") @db.VarChar(64) + status String @default("PENDING") @db.VarChar(16) + createdGameTick BigInt @map("created_game_tick") + expiresGameTick BigInt? @map("expires_game_tick") + resolvedGameTick BigInt? @map("resolved_game_tick") + clockRevision BigInt @map("clock_revision") + deadlineGeneration BigInt @map("deadline_generation") + createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3) + updatedAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at_wall") @db.Timestamp(3) + + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@index([status, expiresGameTick]) + @@map("message_action") +} + model RankData { id Int @id @default(autoincrement()) nationId Int @default(0) @map("nation_id") @@ -857,6 +887,26 @@ model InheritanceLog { @@map("inheritance_log") } +/// WALL_TIME purchase/consume receipt for an inheritance command. The linked +/// input_event owns the authoritative GAME clock coordinate and retry state. +model InheritanceLedger { + id BigInt @id @default(autoincrement()) + requestId String @unique @map("request_id") + userId String @map("user_id") + action String + cost Float + status String @default("APPLIED") + requestedAtWall DateTime @map("requested_at_wall") @db.Timestamp(3) + consumedAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("consumed_at_wall") @db.Timestamp(3) + appliedClockRevision BigInt @map("applied_clock_revision") + appliedDeadlineGeneration BigInt @map("applied_deadline_generation") + createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3) + + @@index([userId, id]) + @@index([status, id]) + @@map("inheritance_ledger") +} + model InheritanceResult { id Int @id @default(autoincrement()) legacyId Int? @unique @map("legacy_id") @@ -897,19 +947,23 @@ model Auction { } model AuctionBid { - id Int @id @default(autoincrement()) - auctionId Int @map("auction_id") - generalId Int @map("general_id") - amount Int - eventId String @map("event_id") - eventAt DateTime @map("event_at") - meta Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + auctionId Int @map("auction_id") + generalId Int @map("general_id") + amount Int + eventId String @map("event_id") + /// Legacy/UI projection of occurredGameTick. Never use as expiry authority. + eventAt DateTime @map("event_at") + occurredGameTick BigInt @map("occurred_game_tick") + requestedAtWall DateTime @map("requested_at_wall") @db.Timestamp(3) + meta Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade) @@index([auctionId, amount]) @@index([auctionId, eventAt]) + @@index([auctionId, occurredGameTick]) @@map("auction_bid") } diff --git a/packages/infra/prisma/migrations/20260903140000_split_message_wall_and_game_time/migration.sql b/packages/infra/prisma/migrations/20260903140000_split_message_wall_and_game_time/migration.sql new file mode 100644 index 00000000..6179436f --- /dev/null +++ b/packages/infra/prisma/migrations/20260903140000_split_message_wall_and_game_time/migration.sql @@ -0,0 +1,160 @@ +-- Message envelopes are WALL_TIME. The existing time/valid_until columns are +-- retained as rolling-deploy projections while actionable gameplay state moves +-- to an explicit GAME_TIME record. +ALTER TABLE message + ADD COLUMN created_at_wall TIMESTAMP(3), + ADD COLUMN delete_until_wall TIMESTAMP(3), + ADD COLUMN tombstoned_at_wall TIMESTAMP(3), + ADD COLUMN occurred_game_tick BIGINT; + +-- Historical rows predate a trustworthy wall-occurrence field. `time` is the +-- only available evidence, so preserve it as the best-effort occurrence while +-- ensuring the migration can never reopen an old five-minute delete window. +UPDATE message +SET created_at_wall = time, + delete_until_wall = LEAST(time + INTERVAL '5 minutes', CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + tombstoned_at_wall = CASE + WHEN lower(COALESCE(message->'option'->>'invalid', 'false')) IN ('true', '1') + THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + ELSE NULL + END, + occurred_game_tick = time_tick; + +ALTER TABLE message + ALTER COLUMN created_at_wall SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + ALTER COLUMN created_at_wall SET NOT NULL, + ALTER COLUMN delete_until_wall SET DEFAULT ((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '5 minutes'), + ALTER COLUMN delete_until_wall SET NOT NULL; + +CREATE INDEX message_mailbox_type_id_idx ON message(mailbox, type, id); +CREATE INDEX message_delete_until_wall_idx ON message(delete_until_wall); + +CREATE TABLE message_action ( + message_id INTEGER PRIMARY KEY REFERENCES message(id) ON DELETE CASCADE, + action_type VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PENDING', + created_game_tick BIGINT NOT NULL, + expires_game_tick BIGINT, + resolved_game_tick BIGINT, + clock_revision BIGINT NOT NULL, + deadline_generation BIGINT NOT NULL, + created_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + updated_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + CONSTRAINT message_action_status_check CHECK (status IN ('PENDING', 'RESOLVED', 'CANCELLED')), + CONSTRAINT message_action_resolution_check CHECK ( + (status = 'PENDING' AND resolved_game_tick IS NULL) + OR (status <> 'PENDING' AND resolved_game_tick IS NOT NULL) + ) +); + +-- Existing actionable payloads used message ticks as their GAME_TIME +-- authority. Backfill once; after this migration message_action is authoritative +-- and NULL never changes the clock domain of the rule. +INSERT INTO message_action ( + message_id, + action_type, + status, + created_game_tick, + expires_game_tick, + resolved_game_tick, + clock_revision, + deadline_generation +) +SELECT + message.id, + message.message->'option'->>'action', + CASE + WHEN message.time_tick IS NULL + OR (message.valid_until < TIMESTAMP '9000-01-01' AND message.valid_until_tick IS NULL) + OR lower(COALESCE(message.message->'option'->>'used', 'false')) IN ('true', '1') + OR lower(COALESCE(message.message->'option'->>'invalid', 'false')) IN ('true', '1') + OR message.valid_until <= message.time + THEN 'RESOLVED' + ELSE 'PENDING' + END, + COALESCE(message.time_tick, 0), + CASE + WHEN message.valid_until_tick IS NULL + OR message.valid_until_tick >= 9007199254740991 + THEN NULL + ELSE message.valid_until_tick + END, + CASE + WHEN message.time_tick IS NULL + OR (message.valid_until < TIMESTAMP '9000-01-01' AND message.valid_until_tick IS NULL) + OR lower(COALESCE(message.message->'option'->>'used', 'false')) IN ('true', '1') + OR lower(COALESCE(message.message->'option'->>'invalid', 'false')) IN ('true', '1') + OR message.valid_until <= message.time + THEN COALESCE(message.valid_until_tick, message.time_tick, 0) + ELSE NULL + END, + COALESCE((SELECT clock_revision FROM world_state ORDER BY id ASC LIMIT 1), 1), + COALESCE((SELECT deadline_generation FROM world_state ORDER BY id ASC LIMIT 1), 1) +FROM message +WHERE jsonb_typeof(message.message->'option') = 'object' + AND NULLIF(message.message->'option'->>'action', '') IS NOT NULL; + +CREATE INDEX message_action_status_expires_game_tick_idx + ON message_action(status, expires_game_tick); + +-- Inheritance requests are WALL_TIME receipts. Their input_event row remains +-- the durable command/effect state and owns the GAME clock fence coordinate. +CREATE TABLE inheritance_ledger ( + id BIGSERIAL PRIMARY KEY, + request_id TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL, + action TEXT NOT NULL, + cost DOUBLE PRECISION NOT NULL, + status TEXT NOT NULL DEFAULT 'APPLIED', + requested_at_wall TIMESTAMP(3) NOT NULL, + consumed_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + applied_clock_revision BIGINT NOT NULL, + applied_deadline_generation BIGINT NOT NULL, + created_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + CONSTRAINT inheritance_ledger_status_check CHECK (status IN ('APPLIED')), + CONSTRAINT inheritance_ledger_cost_check CHECK (cost >= 0) +); + +CREATE INDEX inheritance_ledger_user_id_id_idx ON inheritance_ledger(user_id, id); +CREATE INDEX inheritance_ledger_status_id_idx ON inheritance_ledger(status, id); + +-- Auction bid receipt and gameplay occurrence are different facts. event_at is +-- retained as the GAME_TIME projection used by existing UI and ordering code. +ALTER TABLE auction_bid + ADD COLUMN requested_at_wall TIMESTAMP(3), + ADD COLUMN occurred_game_tick BIGINT; + +UPDATE auction_bid AS bid +SET requested_at_wall = bid.created_at, + occurred_game_tick = ROUND( + EXTRACT(EPOCH FROM (bid.event_at - world.clock_base_time)) + * (36000000::numeric / world.tick_seconds) + )::bigint +FROM world_state AS world; + +ALTER TABLE auction_bid + ALTER COLUMN requested_at_wall SET NOT NULL, + ALTER COLUMN occurred_game_tick SET NOT NULL, + ALTER COLUMN created_at SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +CREATE INDEX auction_bid_auction_occurred_game_tick_idx + ON auction_bid(auction_id, occurred_game_tick); + +-- Selection-pool reselection is expressed in turns. Preserve the old DateTime +-- keys only as projections and make one GAME_TIME authority explicit. +UPDATE general AS actor +SET meta = jsonb_set( + actor.meta, + '{next_change_tick}', + to_jsonb(ROUND( + EXTRACT(EPOCH FROM ( + COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt')::timestamp + - world.clock_base_time + )) * (36000000::numeric / world.tick_seconds) + )::bigint), + true +) +FROM world_state AS world +WHERE COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt') IS NOT NULL + AND COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt') + ~ '^\d{4}-\d{2}-\d{2}T'; diff --git a/packages/infra/prisma/migrations/README.md b/packages/infra/prisma/migrations/README.md index 0d1cf44e..46f18ddb 100644 --- a/packages/infra/prisma/migrations/README.md +++ b/packages/infra/prisma/migrations/README.md @@ -48,6 +48,16 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여 - `select_npc_token`, `select_npc_token_valid_until_idx` - `general_user_id_key` +## 시간 도메인 populated upgrade 검증 + +메시지 envelope의 WALL_TIME, actionable message와 선택 cooldown·경매의 +GAME_TIME backfill, 유산 receipt table, 두 번째 deploy no-op을 전용 tmpfs +PostgreSQL에서 검증합니다. 영속 Docker volume은 만들지 않습니다. + +```sh +pnpm --filter @sammo-ts/infra verify:migration:time-domains +``` + 검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유 database나 Compose volume을 삭제하지 않습니다. diff --git a/packages/infra/scripts/verify-time-domain-migration.sh b/packages/infra/scripts/verify-time-domain-migration.sh new file mode 100644 index 00000000..9148815c --- /dev/null +++ b/packages/infra/scripts/verify-time-domain-migration.sh @@ -0,0 +1,166 @@ +#!/bin/sh +set -eu + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +package_dir="$(dirname "$script_dir")" +prisma_dir="$package_dir/prisma" +target_migration=20260903140000_split_message_wall_and_game_time +task_label=devsam.core2026.time-domain-migration-preflight +run_id="$(date -u +%m%d%H%M%S)_$$" +container_name="sammo-time-domain-preflight-$run_id" +schema_name="time_domain_preflight_$run_id" +work_dir="$(mktemp -d /tmp/sammo-time-domain-preflight.XXXXXX)" +container_created=0 + +case "$container_name" in sammo-time-domain-preflight-[0-9]*_[0-9]*) ;; *) exit 64 ;; esac +case "$schema_name" in time_domain_preflight_[0-9]*_[0-9]*) ;; *) exit 64 ;; esac + +cleanup() { + cleanup_failed=0 + if [ "$container_created" -eq 1 ] && docker inspect "$container_name" >/dev/null 2>&1; then + actual_label="$(docker inspect --format '{{ index .Config.Labels "devsam.core2026.task" }}' "$container_name")" + if [ "$actual_label" != "$task_label" ]; then + echo "refusing to remove container with unexpected ownership label" >&2 + cleanup_failed=1 + elif ! docker rm -f "$container_name" >/dev/null; then + cleanup_failed=1 + fi + fi + case "$work_dir" in + /tmp/sammo-time-domain-preflight.*) rm -r -- "$work_dir" || cleanup_failed=1 ;; + *) cleanup_failed=1 ;; + esac + return "$cleanup_failed" +} +handle_exit() { + exit_status=$? + trap - EXIT HUP INT TERM + if ! cleanup && [ "$exit_status" -eq 0 ]; then exit_status=1; fi + exit "$exit_status" +} +trap handle_exit EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 69; } +[ -d "$prisma_dir/migrations/$target_migration" ] || { echo "target migration is missing" >&2; exit 66; } + +umask 077 +password="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" +password_file="$work_dir/postgres_password" +printf '%s\n' "$password" >"$password_file" + +docker run -d \ + --name "$container_name" \ + --label "devsam.core2026.task=$task_label" \ + --tmpfs /var/lib/postgresql:rw,nodev,nosuid,size=1g \ + --mount "type=bind,source=$password_file,target=/run/secrets/postgres_password,readonly" \ + -e POSTGRES_DB=sammo \ + -e POSTGRES_USER=sammo \ + -e POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \ + -p 127.0.0.1::5432 \ + postgres:18.4-bookworm >/dev/null +container_created=1 + +if [ -n "$(docker inspect --format '{{ range .Mounts }}{{ if eq .Type "volume" }}volume{{ end }}{{ end }}' "$container_name")" ]; then + echo "preflight container unexpectedly owns a Docker volume" >&2 + exit 1 +fi + +attempt=0 +until docker exec "$container_name" pg_isready -U sammo -d sammo >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 60 ]; then docker logs --tail 100 "$container_name" >&2; exit 1; fi + sleep 1 +done + +published_port="$(docker port "$container_name" 5432/tcp)" +published_port="${published_port##*:}" +case "$published_port" in ''|*[!0-9]*) exit 1 ;; esac + +export POSTGRES_HOST=127.0.0.1 +export POSTGRES_PORT="$published_port" +export POSTGRES_DB=sammo +export POSTGRES_USER=sammo +export POSTGRES_PASSWORD="$password" +export POSTGRES_SCHEMA="$schema_name" +unset DATABASE_URL DATABASE_SCHEMA + +stage_prisma="$work_dir/prisma" +mkdir -p "$stage_prisma/migrations" +cp "$prisma_dir/game.prisma" "$stage_prisma/game.prisma" +found_target=0 +for migration_dir in "$prisma_dir"/migrations/[0-9]*; do + migration_name="$(basename "$migration_dir")" + if [ "$migration_name" = "$target_migration" ]; then found_target=1; break; fi + cp -R "$migration_dir" "$stage_prisma/migrations/$migration_name" +done +[ "$found_target" -eq 1 ] || exit 1 + +cd "$package_dir" +PRISMA_SCHEMA="$stage_prisma/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$stage_prisma/game.prisma" >"$work_dir/predecessor.log" + +docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo >/dev/null <"$work_dir/target.log" +PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$work_dir/noop.log" +grep -Fq 'No pending migrations to apply' "$work_dir/noop.log" + +result="$(docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo -tAc " +SET search_path TO \"$schema_name\"; +SELECT + (SELECT count(*) FROM message_action) = 2 + AND (SELECT action_type = 'raiseInvader' AND status = 'PENDING' AND expires_game_tick = 252000000 + FROM message_action WHERE message_id = 920002) + AND (SELECT status = 'RESOLVED' AND resolved_game_tick = 0 + FROM message_action WHERE message_id = 920003) + AND (SELECT requested_at_wall = TIMESTAMP '2026-09-03 01:02:03.456' + AND occurred_game_tick = 36000000 + FROM auction_bid WHERE id = 930002) + AND (SELECT (meta->>'next_change_tick')::bigint = 108000000 FROM general WHERE id = 910001) + AND (SELECT delete_until_wall <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC' FROM message WHERE id = 920001) + AND to_regclass('\"$schema_name\".inheritance_ledger') IS NOT NULL; +" | tail -n 1)" +[ "$result" = "t" ] || { echo "time-domain migration assertions failed: $result" >&2; exit 1; } + +echo "time-domain populated migration and no-op redeploy passed" diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index c6205460..ba894c51 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -13,6 +13,7 @@ export interface DatabaseClient { trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate; messageReadState: GamePrisma.MessageReadStateDelegate; message: GamePrisma.MessageDelegate; + messageAction: GamePrisma.MessageActionDelegate; city: GamePrisma.CityDelegate; nation: GamePrisma.NationDelegate; diplomacy: GamePrisma.DiplomacyDelegate; @@ -38,6 +39,7 @@ export interface DatabaseClient { nationBetting: GamePrisma.NationBettingDelegate; nationBet: GamePrisma.NationBetDelegate; inheritanceLog: GamePrisma.InheritanceLogDelegate; + inheritanceLedger: GamePrisma.InheritanceLedgerDelegate; inheritanceResult: GamePrisma.InheritanceResultDelegate; inheritanceUserState: GamePrisma.InheritanceUserStateDelegate; boardPost: GamePrisma.BoardPostDelegate; diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index 51abe88c..6100c22a 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -11,4 +11,5 @@ export * from './readModelOutboxDispatcher.js'; export * from './readModelCoverageActivation.js'; export * from './gameSchemaAdvisoryLock.js'; export * from './inputEventClock.js'; +export * from './messageEnvelope.js'; export * from './webPushOutbox.js'; diff --git a/packages/infra/src/messageEnvelope.ts b/packages/infra/src/messageEnvelope.ts new file mode 100644 index 00000000..dcde04ff --- /dev/null +++ b/packages/infra/src/messageEnvelope.ts @@ -0,0 +1,98 @@ +import type { MessageRecordDraft } from '@sammo-ts/logic'; + +import { GamePrisma } from './gamePrisma.js'; + +export interface MessageGameContext { + occurredGameTick: bigint; + clockRevision: bigint; + deadlineGeneration: bigint; + expiresGameTick: bigint | null; +} + +export type MessageEnvelopeDatabase = Pick; + +const resolveActionType = (draft: MessageRecordDraft): string | null => { + const option = draft.payload.option; + if (!option || typeof option !== 'object' || Array.isArray(option)) return null; + const action = Reflect.get(option, 'action'); + return typeof action === 'string' && action.trim() !== '' ? action : null; +}; + +/** + * Persists a WALL_TIME message envelope and, only for an explicit actionable + * payload, a separate GAME_TIME action row. PostgreSQL supplies the envelope + * occurrence and delete deadline; caller clocks are compatibility projections. + */ +export const persistMessageEnvelope = async ( + db: MessageEnvelopeDatabase, + draft: MessageRecordDraft, + gameContext: MessageGameContext | null = null +): Promise => { + const actionType = resolveActionType(draft); + if (actionType !== null && gameContext === null) { + throw new Error(`Actionable message ${actionType} requires an authoritative game clock context.`); + } + + const occurredGameTick = gameContext?.occurredGameTick ?? null; + const legacyValidUntilTick = actionType === null ? null : gameContext!.expiresGameTick; + const rows = await db.$queryRaw>(GamePrisma.sql` + WITH wall AS ( + SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall + ), inserted AS ( + INSERT INTO message ( + mailbox, + type, + src, + dest, + time, + time_tick, + valid_until, + valid_until_tick, + created_at_wall, + delete_until_wall, + occurred_game_tick, + message + ) + SELECT + ${draft.mailbox}, + ${draft.msgType}, + ${draft.srcId}, + ${draft.destId}, + ${draft.time}, + ${occurredGameTick}, + ${draft.validUntil}, + ${legacyValidUntilTick}, + wall.now_wall, + wall.now_wall + INTERVAL '5 minutes', + ${occurredGameTick}, + CAST(${JSON.stringify(draft.payload)} AS jsonb) + FROM wall + RETURNING id + ), action AS ( + INSERT INTO message_action ( + message_id, + action_type, + status, + created_game_tick, + expires_game_tick, + clock_revision, + deadline_generation + ) + SELECT + inserted.id, + ${actionType}, + 'PENDING', + ${gameContext?.occurredGameTick ?? 0n}, + ${gameContext?.expiresGameTick ?? null}, + ${gameContext?.clockRevision ?? 0n}, + ${gameContext?.deadlineGeneration ?? 0n} + FROM inserted + WHERE ${actionType} IS NOT NULL + RETURNING message_id + ) + SELECT id FROM inserted + `); + const id = rows[0]?.id; + if (!id) throw new Error('Failed to persist message envelope.'); + return id; +}; diff --git a/packages/infra/src/readModelOutboxDispatcher.ts b/packages/infra/src/readModelOutboxDispatcher.ts index 5a317a2b..d0d40c6b 100644 --- a/packages/infra/src/readModelOutboxDispatcher.ts +++ b/packages/infra/src/readModelOutboxDispatcher.ts @@ -2,7 +2,7 @@ import { parseReadModelOutboxPayload, type ReadModelOutboxPayloadV1 } from '@sam import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; -export interface ReadModelOutboxDatabase extends Pick { +export interface ReadModelOutboxDatabase extends Pick { readModelOutbox: GamePrisma.ReadModelOutboxDelegate; } @@ -58,15 +58,16 @@ export const claimReadModelOutboxBatch = async ( } const limit = normalizeLimit(options.limit); const leaseMs = normalizeDuration(options.leaseMs, 30_000); - const now = options.now ?? new Date(); - const leaseExpiredBefore = new Date(now.getTime() - leaseMs); + const nowSql = options.now + ? GamePrisma.sql`${options.now}` + : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`; const rows = await db.$queryRaw(GamePrisma.sql` WITH candidates AS ( SELECT "id" FROM "read_model_outbox" WHERE "delivered_at" IS NULL - AND "available_at" <= ${now} - AND ("locked_at" IS NULL OR "locked_at" < ${leaseExpiredBefore}) + AND "available_at" <= ${nowSql} + AND ("locked_at" IS NULL OR "locked_at" < ${nowSql} - ${leaseMs} * INTERVAL '1 millisecond') ORDER BY "id" LIMIT ${limit} FOR UPDATE SKIP LOCKED @@ -74,7 +75,7 @@ export const claimReadModelOutboxBatch = async ( UPDATE "read_model_outbox" AS outbox SET "attempts" = outbox."attempts" + 1, - "locked_at" = ${now}, + "locked_at" = ${nowSql}, "lock_owner" = ${options.owner}, "last_error" = NULL FROM candidates @@ -89,32 +90,43 @@ export const markReadModelOutboxDelivered = async ( db: ReadModelOutboxDatabase, input: { id: bigint; owner: string; deliveredAt?: Date } ): Promise => { - const result = await db.readModelOutbox.updateMany({ - where: { id: input.id, lockOwner: input.owner, deliveredAt: null }, - data: { - deliveredAt: input.deliveredAt ?? new Date(), - lockedAt: null, - lockOwner: null, - lastError: null, - }, - }); - return result.count === 1; + const deliveredAtSql = input.deliveredAt + ? GamePrisma.sql`${input.deliveredAt}` + : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`; + return ( + (await db.$executeRaw(GamePrisma.sql` + UPDATE "read_model_outbox" + SET "delivered_at" = ${deliveredAtSql}, + "locked_at" = NULL, + "lock_owner" = NULL, + "last_error" = NULL + WHERE "id" = ${input.id} + AND "lock_owner" = ${input.owner} + AND "delivered_at" IS NULL + `)) === 1 + ); }; export const releaseReadModelOutbox = async ( db: ReadModelOutboxDatabase, - input: { id: bigint; owner: string; error: unknown; availableAt: Date } + input: { id: bigint; owner: string; error: unknown; availableAt?: Date; availableAfterMs?: number } ): Promise => { - const result = await db.readModelOutbox.updateMany({ - where: { id: input.id, lockOwner: input.owner, deliveredAt: null }, - data: { - availableAt: input.availableAt, - lockedAt: null, - lockOwner: null, - lastError: formatDispatchError(input.error), - }, - }); - return result.count === 1; + const availableAtSql = input.availableAt + ? GamePrisma.sql`${input.availableAt}` + : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + + ${normalizeDuration(input.availableAfterMs, 1_000)} * INTERVAL '1 millisecond'`; + return ( + (await db.$executeRaw(GamePrisma.sql` + UPDATE "read_model_outbox" + SET "available_at" = ${availableAtSql}, + "locked_at" = NULL, + "lock_owner" = NULL, + "last_error" = ${formatDispatchError(input.error)} + WHERE "id" = ${input.id} + AND "lock_owner" = ${input.owner} + AND "delivered_at" IS NULL + `)) === 1 + ); }; export const dispatchReadModelOutboxBatch = async ( @@ -122,14 +134,14 @@ export const dispatchReadModelOutboxBatch = async ( publish: (payload: ReadModelOutboxPayloadV1, outboxId: bigint) => Promise, options: ReadModelOutboxDispatchOptions ): Promise => { - const now = options.now ?? (() => new Date()); + const testNow = options.now; const retryBaseMs = normalizeDuration(options.retryBaseMs, 1_000); const retryMaxMs = Math.max(retryBaseMs, normalizeDuration(options.retryMaxMs, 60_000)); const claimed = await claimReadModelOutboxBatch(db, { owner: options.owner, limit: options.limit, leaseMs: options.leaseMs, - now: now(), + ...(testNow ? { now: testNow() } : {}), }); let delivered = 0; let failed = 0; @@ -141,7 +153,13 @@ export const dispatchReadModelOutboxBatch = async ( throw new Error(`Read-model outbox ${item.id.toString()} has an invalid payload.`); } await publish(payload, item.id); - if (!(await markReadModelOutboxDelivered(db, { id: item.id, owner: options.owner, deliveredAt: now() }))) { + if ( + !(await markReadModelOutboxDelivered(db, { + id: item.id, + owner: options.owner, + ...(testNow ? { deliveredAt: testNow() } : {}), + })) + ) { throw new Error(`Read-model outbox ${item.id.toString()} lost its delivery lease.`); } delivered += 1; @@ -151,7 +169,9 @@ export const dispatchReadModelOutboxBatch = async ( id: item.id, owner: options.owner, error, - availableAt: new Date(now().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)), + ...(testNow + ? { availableAt: new Date(testNow().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)) } + : { availableAfterMs: retryDelayMs(item.attempts, retryBaseMs, retryMaxMs) }), }); } } diff --git a/packages/infra/test/readModelOutboxDispatcher.test.ts b/packages/infra/test/readModelOutboxDispatcher.test.ts index 9e93f05b..c72d4c28 100644 --- a/packages/infra/test/readModelOutboxDispatcher.test.ts +++ b/packages/infra/test/readModelOutboxDispatcher.test.ts @@ -15,9 +15,11 @@ const validPayload = { const createDb = (rows: readonly object[]) => { const queryRaw = vi.fn().mockResolvedValue(rows); const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const executeRaw = vi.fn().mockResolvedValue(1); return { - db: { $queryRaw: queryRaw, readModelOutbox: { updateMany } } as unknown as GamePrismaClient, + db: { $queryRaw: queryRaw, $executeRaw: executeRaw, readModelOutbox: { updateMany } } as unknown as GamePrismaClient, queryRaw, + executeRaw, updateMany, }; }; @@ -46,12 +48,7 @@ describe('read-model outbox dispatcher', () => { expect(result).toEqual({ claimed: 1, delivered: 1, failed: 0 }); expect(publish).toHaveBeenCalledWith(validPayload, 1n); - expect(fixture.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 1n, lockOwner: 'worker-a', deliveredAt: null }, - data: expect.objectContaining({ deliveredAt: new Date('2026-08-16T00:00:00.000Z') }), - }) - ); + expect(fixture.executeRaw).toHaveBeenCalledOnce(); }); it('releases failed and malformed rows with bounded retry state', async () => { @@ -69,20 +66,7 @@ describe('read-model outbox dispatcher', () => { expect(result).toEqual({ claimed: 2, delivered: 0, failed: 2 }); expect(publish).toHaveBeenCalledTimes(1); - expect(fixture.updateMany).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - where: { id: 1n, lockOwner: 'worker-a', deliveredAt: null }, - data: expect.objectContaining({ availableAt: new Date('2026-08-16T00:00:04.000Z') }), - }) - ); - expect(fixture.updateMany).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - where: { id: 2n, lockOwner: 'worker-a', deliveredAt: null }, - data: expect.objectContaining({ availableAt: new Date('2026-08-16T00:00:01.000Z') }), - }) - ); + expect(fixture.executeRaw).toHaveBeenCalledTimes(2); }); it('prunes only a bounded delivered batch', async () => { diff --git a/release-manifest.json b/release-manifest.json index 1f3d023c..4b1a0bd3 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260825000000_add_bulk_release_batches", - "gameSchemaHead": "20260903103000_add_input_event_clock_processing", + "gameSchemaHead": "20260903140000_split_message_wall_and_game_time", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } diff --git a/tools/check-game-clock-participants.mjs b/tools/check-game-clock-participants.mjs index 78692b79..a630982e 100644 --- a/tools/check-game-clock-participants.mjs +++ b/tools/check-game-clock-participants.mjs @@ -26,6 +26,8 @@ for (const modelMatch of schema.matchAll(/model\s+(\w+)\s*\{([\s\S]*?)\n\}/g)) { databaseField.endsWith('_tick') || databaseField === 'clock_revision' || databaseField === 'deadline_generation' || + databaseField.endsWith('_clock_revision') || + databaseField.endsWith('_deadline_generation') || (table.startsWith('clock_') && databaseField.endsWith('_revision')) ) { discovered.push(`${table}.${databaseField}`); @@ -57,17 +59,21 @@ for (const requiredKey of [ 'world-clock', 'turn-cursor', 'general-next-turn', + 'selection-reselection-deadline', 'general-recent-war-occurrence', 'auction-open-occurrence', + 'auction-bid-occurrence', 'auction-deadline', 'auction-finalizing-recovery', - 'message-occurrence', - 'message-expiry', + 'message-action-occurrence', + 'message-action-expiry', + 'message-action-clock-coordinate', + 'inheritance-effect-coordinate', 'vote-start-occurrence', 'vote-end-deadline', 'select-pool-reservation', 'npc-selection-window', - 'accepted-command-coordinate', + 'daemon-command-coordinate', 'tournament-deadlines', 'movable-json-rule-anchors', 'unification-wait', diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 8d9476da..45aab731 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -516,7 +516,10 @@ describe('auction integration flow', () => { WHERE id = ${auction.id} ` ); - const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); + const result = await transport.requestCommand( + { type: 'auctionFinalize', auctionId: auction.id, expectedCloseTick: finalizeTick }, + 30_000 + ); expect(result).toMatchObject({ type: 'auctionFinalize', ok: true }); const finished = await prisma.auction.findUnique({ @@ -705,13 +708,16 @@ describe('auction integration flow', () => { closeTick: BigInt(turnDaemon.world.dateToGameTick(siblingCloseAt)), }, }); + const siblingBidGameAt = turnDaemon.world.getGameNow(new Date()); await prisma.auctionBid.create({ data: { auctionId: siblingAuction.id, generalId: spareBidder.generalId, amount: 350, eventId: `same-slot-race-${siblingAuction.id}`, - eventAt: turnDaemon.world.getGameNow(new Date()), + eventAt: siblingBidGameAt, + occurredGameTick: BigInt(turnDaemon.world.dateToGameTick(siblingBidGameAt)), + requestedAtWall: new Date(), }, }); await expect( @@ -752,7 +758,10 @@ describe('auction integration flow', () => { WHERE id = ${auction.id} ` ); - const result = await directTransport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); + const result = await directTransport.requestCommand( + { type: 'auctionFinalize', auctionId: auction.id, expectedCloseTick: finalizeTick }, + 30_000 + ); expect(result).toMatchObject({ type: 'auctionFinalize', ok: false }); const reopened = await prisma.auction.findUnique({ @@ -914,7 +923,10 @@ describe('auction integration flow', () => { WHERE id = ${auction.id} ` ); - const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); + const result = await transport.requestCommand( + { type: 'auctionFinalize', auctionId: auction.id, expectedCloseTick: finalizeTick }, + 30_000 + ); expect(result).toMatchObject({ type: 'auctionFinalize', ok: true }); const winner = await prisma.general.findUnique({ @@ -1205,7 +1217,10 @@ describe('auction integration flow', () => { }, }); const transport = new DatabaseTurnDaemonTransport(prisma, 30_000); - const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); + const result = await transport.requestCommand( + { type: 'auctionFinalize', auctionId: auction.id, expectedCloseTick: finalizeTick }, + 30_000 + ); expect(result).toMatchObject({ type: 'auctionFinalize', ok: true }); await expect(prisma.auction.findUniqueOrThrow({ where: { id: auction.id } })).resolves.toMatchObject({ diff --git a/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts index dc210fe1..b797bcd7 100644 --- a/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts +++ b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts @@ -407,7 +407,7 @@ integration('NPC possession selector Ref differential', () => { refresh: hasPreviousToken, keepIds: testCase.keepIds, now: acceptedAt(fixture), - acceptedGameTick, + createdGameTick: acceptedGameTick, selectionObserver: { onRandomDraw: (value) => randomDraws.push(value), onCandidateDraw: (selectedId) => draws.push(Number(selectedId)),