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 eff528d6..03589514 100644 --- a/app/game-api/src/auction/worker.ts +++ b/app/game-api/src/auction/worker.ts @@ -1,4 +1,7 @@ import { + CLOCK_OPERATION_PERSISTENCE_LOCK, + GamePrisma, + acquireGameSchemaAdvisoryXactLock, createGamePostgresConnector, createRedisConnector, type GamePrismaClient, @@ -10,6 +13,7 @@ import { resolveGameApiConfigFromEnv } from '../config.js'; import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js'; import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js'; import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js'; +import { ensureActiveRedisClockFence } from '../services/redisClockFence.js'; import { buildAuctionTimerKeys } from './keys.js'; import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js'; @@ -24,13 +28,27 @@ interface RedisTimerClient { zAdd(key: string, values: Array<{ score: number; value: string }>): Promise; zRem(key: string, values: string | string[]): Promise; zRemRangeByScore(key: string, min: number, max: number): Promise; + eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise; } +const POP_DUE_AUCTIONS_SCRIPT = ` +if redis.call('GET', KEYS[2]) ~= ARGV[1] + or redis.call('GET', KEYS[3]) ~= ARGV[2] + or redis.call('GET', KEYS[4]) ~= 'RUNNING' then + return { '__CLOCK_FENCE__' } +end +local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[3], 'LIMIT', 0, ARGV[4]) +if #ids > 0 then + redis.call('ZREM', KEYS[1], unpack(ids)) +end +return ids +`; + const AUCTION_FINALIZE_RECOVERY_LIMIT = 1; interface AuctionFinalizeDeadline { closeAt: Date; - closeTick: bigint | null; + closeTick: bigint; } interface AuctionFinalizeCommand { @@ -38,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}`); @@ -63,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; }; @@ -83,7 +85,7 @@ const buildAuctionFinalizeCommand = ( requestId, auctionId, expectedCloseAt: deadline.closeAt.toISOString(), - ...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }), + expectedCloseTick: readSafeCloseTick(deadline.closeTick), }); const isMatchingAuctionFinalizeEvent = ( @@ -95,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 && @@ -115,12 +114,29 @@ const isSuccessfulAuctionFinalizeResult = (result: unknown, auctionId: number): return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId; }; -const popDueAuctionIds = async ( +export const popDueAuctionIds = async ( redis: RedisTimerClient, timerKey: string, nowMs: number, - batchSize: number + batchSize: number, + clockFence?: { + activeRevisionKey: string; + deadlineGenerationKey: string; + phaseKey: string; + revision: number; + generation: number; + } ): Promise => { + if (clockFence) { + if (!redis.eval) throw new Error('Redis EVAL is required for revision-fenced auction due-pop.'); + const result = await redis.eval(POP_DUE_AUCTIONS_SCRIPT, { + keys: [timerKey, clockFence.activeRevisionKey, clockFence.deadlineGenerationKey, clockFence.phaseKey], + arguments: [String(clockFence.revision), String(clockFence.generation), String(nowMs), String(batchSize)], + }); + if (!Array.isArray(result)) throw new Error('Auction due-pop returned an invalid Redis result.'); + if (result[0] === '__CLOCK_FENCE__') return []; + return result.map(String); + } const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } }); if (ids.length > 0) { await redis.zRem(timerKey, ids); @@ -157,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' }, @@ -182,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), }); @@ -205,6 +223,8 @@ export const processDueAuctionId = async (options: { nowMs: number; nowTick?: number | null; historyNowMs?: number; + expectedClockRevision?: number; + expectedDeadlineGeneration?: number; }): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => { const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options; const auctionId = Number(id); @@ -213,6 +233,29 @@ export const processDueAuctionId = async (options: { } const now = new Date(nowMs); const outcome = await db.$transaction(async (transaction) => { + if (options.expectedClockRevision !== undefined || options.expectedDeadlineGeneration !== undefined) { + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + const [world] = await transaction.$queryRaw< + Array<{ clockPhase: string | null; clockRevision: bigint; deadlineGeneration: bigint }> + >(GamePrisma.sql` + SELECT + clock_phase AS "clockPhase", + clock_revision AS "clockRevision", + deadline_generation AS "deadlineGeneration" + FROM world_state + ORDER BY id ASC + LIMIT 1 + FOR UPDATE + `); + if ( + !world || + world.clockPhase !== 'RUNNING' || + world.clockRevision !== BigInt(options.expectedClockRevision ?? -1) || + world.deadlineGeneration !== BigInt(options.expectedDeadlineGeneration ?? -1) + ) { + return { status: 'RESCHEDULED' as const, clockFenceFailed: true }; + } + } const current = await transaction.auction.findUnique({ where: { id: auctionId }, select: { status: true, closeAt: true, closeTick: true }, @@ -221,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 }; } @@ -233,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({ @@ -264,14 +298,14 @@ export const processDueAuctionId = async (options: { 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}` ); } } @@ -284,6 +318,10 @@ export const processDueAuctionId = async (options: { return 'PENDING'; } if (outcome.status === 'RESCHEDULED') { + if ('clockFenceFailed' in outcome) { + await redis.zAdd(timerKey, [{ score: nowTick ?? nowMs, value: id }]); + return 'RESCHEDULED'; + } const gameTime = await loadCurrentGameTime(db, now); await redis.zAdd(timerKey, [ { @@ -315,18 +353,30 @@ 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; - if (operationalNowMs >= nextResyncAt) { + if (gameTime.phase && gameTime.phase !== 'RUNNING') { + await waitForWorkerPoll(control.signal, config.auctionTimerPollMs); + continue; + } + const clockFence = gameTime.phase + ? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime) + : null; + if (gameTime.phase && !clockFence) { + await waitForWorkerPoll(control.signal, config.auctionTimerPollMs); + continue; + } + 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({ @@ -345,7 +395,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom if (historyTrimBefore > 0) { await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore); } - const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100); + const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100, clockFence ?? undefined); if (dueIds.length > 0) { for (const id of dueIds) { try { @@ -358,6 +408,12 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom nowMs: gameNowMs, nowTick: gameTime.tick, historyNowMs: operationalNowMs, + ...(clockFence + ? { + expectedClockRevision: clockFence.revision, + expectedDeadlineGeneration: clockFence.generation, + } + : {}), }); if (outcome === 'PENDING') { pendingFinalizationIds.add(Number(id)); @@ -395,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/context.ts b/app/game-api/src/context.ts index 95a80255..d0614d99 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -65,7 +65,15 @@ export type WorldStateMeta = z.infer; type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload>; type PrismaGeneralRow = GamePrisma.GeneralGetPayload>; -type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'; +type WorldClockFields = + | 'clockBaseTime' + | 'clockTick' + | 'clockMode' + | 'clockWallAnchor' + | 'lastTurnTick' + | 'clockPhase' + | 'clockRevision' + | 'deadlineGeneration'; type GeneralClockFields = 'turnTick' | 'recentWarTick'; // Transitional API fixtures may still model the pre-clock row. Runtime Prisma diff --git a/app/game-api/src/daemon/databaseTransport.ts b/app/game-api/src/daemon/databaseTransport.ts index e2ae7b6d..96fb60d8 100644 --- a/app/game-api/src/daemon/databaseTransport.ts +++ b/app/game-api/src/daemon/databaseTransport.ts @@ -1,10 +1,15 @@ import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; -import { acquireGameSchemaAdvisoryXactLock, type DatabaseClient, type GamePrisma } from '@sammo-ts/infra'; +import { + acquireGameSchemaAdvisoryXactLock, + readInputEventClockCoordinate, + type DatabaseClient, + type GamePrisma, +} 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; @@ -82,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 }, @@ -103,15 +111,14 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { try { if (command.type === 'npcPossessGeneral' && this.db.$transaction) { const rejectionReason = await this.db.$transaction(async (transaction) => { + const coordinate = await readInputEventClockCoordinate(transaction); await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global'); await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`); - const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000); - const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now; const token = await transaction.npcSelectionToken.findFirst({ where: { ownerUserId: command.userId, nonce: command.tokenNonce, - validUntil: { gte: acceptedGameAt }, + validUntilTick: { gte: coordinate.gameTick }, }, select: { pickResult: true }, }); @@ -126,18 +133,20 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { ) { return '선택한 장수가 목록에 없습니다.'; } - const acceptedCommand: Extract = { - ...(durableCommand as Extract), - acceptedGameAt: acceptedGameAt.toISOString(), - }; - await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt); + await this.createInputEvent(transaction, durableCommand, requestId); return null; }); if (rejectionReason) { throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason); } } else { - await this.createInputEvent(this.db, durableCommand, requestId); + if (this.db.$transaction) { + await this.db.$transaction(async (transaction) => { + await this.createInputEvent(transaction, durableCommand, requestId); + }); + } else { + await this.createInputEvent(this.db, durableCommand, requestId); + } } } catch (error) { if (error instanceof RejectedNpcPossessionCommandError) { @@ -165,8 +174,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { private async createInputEvent( db: DatabaseClient, command: TurnDaemonCommand, - requestId: string, - createdAt?: Date + requestId: string ): Promise { await db.inputEvent.create({ data: { @@ -175,7 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { eventType: command.type, payload: asJson(command), actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null, - ...(createdAt ? { createdAt } : {}), + // PostgreSQL owns created_at WALL_TIME. ENGINE assigns the + // authoritative game coordinate when the daemon claims it. }, }); } @@ -192,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 }, @@ -204,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 1a0993dd..b0b29e9e 100644 --- a/app/game-api/src/inputEventBoundary.ts +++ b/app/game-api/src/inputEventBoundary.ts @@ -1,6 +1,11 @@ import { createHash } from 'node:crypto'; -import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra'; +import { + CLOCK_OPERATION_PERSISTENCE_LOCK, + GamePrisma, + acquireGameSchemaAdvisoryXactLock, + type DatabaseClient as InfraDatabaseClient, +} from '@sammo-ts/infra'; import type { DatabaseClient } from './context.js'; @@ -29,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') { @@ -85,6 +88,9 @@ const insertPendingIfAbsent = async ( actor_user_id, status, attempts, + accepted_game_tick, + accepted_clock_revision, + accepted_deadline_generation, created_at ) VALUES ( @@ -95,6 +101,9 @@ const insertPendingIfAbsent = async ( ${options.actorUserId}, 'PENDING'::"InputEventStatus", 0, + NULL, + NULL, + NULL, CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ) ON CONFLICT (request_id) DO NOTHING @@ -153,20 +162,22 @@ const claimInputEvent = async ( requestId: string, payloadIdentity: ApiInputPayloadIdentity ): 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(), - 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 ( @@ -177,6 +188,7 @@ const markUnexpectedFailure = async ( actorUserId: string | null; payloadIdentity: ApiInputPayloadIdentity; error: unknown; + acquireClockFence: boolean; } ): Promise => { if (!db.$transaction) return; @@ -184,6 +196,9 @@ const markUnexpectedFailure = async ( try { await db.$transaction(async (transaction) => { + 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); @@ -192,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 @@ -220,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); @@ -233,6 +249,9 @@ export const executeInputEvent = async (options: { let outcome: InputEventOutcome; try { outcome = await db.$transaction(async (transaction) => { + 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 }); @@ -258,36 +277,41 @@ export const executeInputEvent = async (options: { 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..9732befc 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -1,11 +1,19 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; +import { asRecord, ChangeJournal } from '@sammo-ts/common'; +import { writeReadModelChangeJournal } from '@sammo-ts/infra'; 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, + engineAuthedProcedure, + router, + wallAuthedProcedure, +} from '../../trpc.js'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, @@ -20,13 +28,13 @@ 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'; +import { executeInputEvent } from '../../inputEventBoundary.js'; const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); @@ -231,7 +239,7 @@ export const messagesRouter = router({ })), }; }), - readLatest: authedProcedure + readLatest: wallAuthedProcedure .input( z.object({ generalId: z.number().int().positive(), @@ -264,7 +272,7 @@ export const messagesRouter = router({ `; return { ok: true }; }), - delete: authedProcedure + delete: wallAuthedProcedure .input( z.object({ generalId: z.number().int().positive(), @@ -289,17 +297,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,9 +316,9 @@ 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 + respond: engineAuthedProcedure .input( z.object({ generalId: z.number().int().positive(), @@ -342,29 +349,80 @@ export const messagesRouter = router({ } return { result: commandResult.ok, reason: commandResult.reason }; } - const result = await respondToDiplomaticMessage({ + const ownsChangeJournal = !ctx.changeJournal; + const changeJournal = ctx.changeJournal ?? new ChangeJournal(); + let journalPersisted = false; + const response = await executeInputEvent({ db: ctx.db, - actor: general, - messageId: input.messageId, - response: input.response, + requestId: `messages.respond.diplomatic:${input.messageId}`, + eventType: 'messages.respond.diplomatic', + payload: input, + actorUserId: ctx.auth?.user.id, + execute: async (transaction) => { + const transactionContext = { ...ctx, db: transaction, changeJournal }; + const transactionGeneral = ownsChangeJournal + ? await getOwnedGeneral(transactionContext, input.generalId) + : general; + const result = await respondToDiplomaticMessage({ + db: transaction, + actor: transactionGeneral, + messageId: input.messageId, + response: input.response, + }); + markMessageMailboxes(transactionContext, result.affectedMailboxes); + for (const generalId of result.affectedGeneralRecordIds) { + changeJournal.mark('records.general', generalId); + } + for (const nationId of result.affectedNationIds) { + changeJournal.mark('nation.content', nationId); + } + for (const cityId of result.affectedCityIds) { + changeJournal.mark('city.content', cityId); + } + if (result.affectedCityIds.length > 0) { + changeJournal.mark('map.world'); + } + if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) { + changeJournal.mark('dashboard.global'); + } + if (ownsChangeJournal) { + journalPersisted = Boolean( + await writeReadModelChangeJournal(transaction, changeJournal.snapshot()) + ); + } + return { + result: result.result, + reason: result.reason, + affectedNationIds: result.affectedNationIds, + affectedCityIds: result.affectedCityIds, + }; + }, }); - markMessageMailboxes(ctx, result.affectedMailboxes); - for (const generalId of result.affectedGeneralRecordIds) { - ctx.changeJournal?.mark('records.general', generalId); + if (journalPersisted) { + ctx.readModelOutbox?.wake(); } - for (const nationId of result.affectedNationIds) { - ctx.changeJournal?.mark('nation.content', nationId); + if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) { + if (!ctx.auth) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const synchronized = await ctx.turnDaemon.requestCommand({ + type: 'syncDiplomaticResponse', + userId: ctx.auth.user.id, + generalId: general.id, + messageId: input.messageId, + nationIds: response.affectedNationIds, + cityIds: response.affectedCityIds, + }); + if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) { + const synchronizationReason = + synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined; + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.', + }); + } } - for (const cityId of result.affectedCityIds) { - ctx.changeJournal?.mark('city.content', cityId); - } - if (result.affectedCityIds.length > 0) { - ctx.changeJournal?.mark('map.world'); - } - if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) { - ctx.changeJournal?.mark('dashboard.global'); - } - return { result: result.result, reason: result.reason }; + return { result: response.result, reason: response.reason }; }), getOld: authedProcedure .input( @@ -420,7 +478,7 @@ export const messagesRouter = router({ ...messageBuckets, }; }), - send: accessAuthedInputProcedure( + send: accessWallAuthedInputProcedure( z.object({ generalId: z.number().int().positive(), mailbox: z.number().int(), @@ -436,7 +494,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 8e61d694..745513c7 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -5,12 +5,14 @@ import { asRecord } from '@sammo-ts/common'; import type { TournamentType } from '@sammo-ts/logic'; import type { TournamentState } from '../../tournament/types.js'; -import { TournamentStore } from '../../tournament/store.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, ensureBettingRedisClockFence } from '../../services/redisClockFence.js'; +import { loadClockAdminStatus } from '../../services/clockReadiness.js'; const hasAdminRole = (roles: string[], profileName: string): boolean => { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { @@ -44,6 +46,63 @@ const adminProcedure = authedProcedure.use(({ ctx, next }) => { return next(); }); +const withTournamentClockMutation = 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 ensureActiveRedisClockFence(ctx.redis, ctx.profile.name, gameTime); + if (!fence) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Clock reconciliation is incomplete; tournament mutation is disabled.', + }); + } + const clockContext: TournamentClockContext = { + phase: fence.phase, + revision: fence.revision, + deadlineGeneration: fence.generation, + dateToTick: gameTime.dateToTick, + }; + 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), @@ -143,7 +202,10 @@ export const tournamentRouter = router({ const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); return store.getState(); }), - getAdminStatus: adminProcedure.query(async () => ({ ok: true })), + getAdminStatus: adminProcedure.query(async ({ ctx }) => ({ + ok: true, + clock: await loadClockAdminStatus(ctx.db), + })), getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => { await getMyGeneral(ctx); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); @@ -271,7 +333,7 @@ export const tournamentRouter = router({ }), setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - return store.withMutationLock(async () => { + return withTournamentClockMutation(ctx, store, async () => { await store.setState({ ...input, type: input.type as TournamentType, @@ -281,7 +343,7 @@ export const tournamentRouter = router({ }), patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - return store.withMutationLock(async () => { + return withTournamentClockMutation(ctx, store, async () => { const current = await store.getState(); if (!current) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); @@ -297,21 +359,21 @@ export const tournamentRouter = router({ }), setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - return store.withMutationLock(async () => { + return withTournamentClockMutation(ctx, store, async () => { await store.setParticipants(input); return { ok: true, count: input.length }; }); }), setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - return store.withMutationLock(async () => { + return withTournamentClockMutation(ctx, store, async () => { await store.setMatches(input); return { ok: true, count: input.length }; }); }), setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - return store.withMutationLock(async () => { + return withTournamentClockMutation(ctx, store, async () => { await store.setBettingEntries(input); return { ok: true, count: input.length }; }); @@ -347,7 +409,7 @@ export const tournamentRouter = router({ }; }); - return store.withMutationLock(async () => { + return withTournamentClockMutation(ctx, store, async () => { await store.setParticipants(participants); return { ok: true, count: participants.length }; }); @@ -394,7 +456,7 @@ export const tournamentRouter = router({ join: authedProcedure.mutation(async ({ ctx }) => { const general = await getMyGeneral(ctx); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - return store.withMutationLock(async () => { + return withTournamentClockMutation(ctx, store, async () => { const state = await store.getState(); if (!state || state.stage !== 1 || state.participantsLockedAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' }); @@ -459,7 +521,7 @@ export const tournamentRouter = router({ }), cancel: adminProcedure.mutation(async ({ ctx }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - return store.withMutationLock(async () => { + return withTournamentClockMutation(ctx, store, async () => { const state = await store.getState(); if (!state) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); @@ -513,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(), @@ -523,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 store.withMutationLock(async () => { + return withTournamentBetClockMutation(ctx, store, async () => { const state = await store.getState(); if (!state || state.stage !== 6) { throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' }); @@ -558,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 }], }); @@ -573,6 +639,7 @@ export const tournamentRouter = router({ const rankResult = await ctx.turnDaemon.requestCommand({ type: 'adjustGeneralMeta', + requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'), reason: 'tournamentBet', adjustments: [ { @@ -584,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 }], }); @@ -600,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/server.ts b/app/game-api/src/server.ts index da0517a0..2dc4550e 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -50,6 +50,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js'; import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js'; import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js'; import { scopeHttpIdempotencyKey } from './requestId.js'; +import { loadClockReadiness } from './services/clockReadiness.js'; const extractBearerToken = (value: string | string[] | undefined): string | null => { if (!value) { @@ -420,12 +421,17 @@ export const createGameApiServer = async () => { request.raw.on('aborted', close); }); - app.get('/healthz', async () => ({ - ok: true, - profile: config.profileName, - postgresPool: postgres.getPoolStats(), - accountIconReconciliation: accountIconResetReconciler.getHealth(), - })); + app.get('/healthz', async (_request, reply) => { + const clock = await loadClockReadiness(postgres.prisma); + if (!clock.reconciliationComplete) reply.code(503); + return { + ok: clock.reconciliationComplete, + profile: config.profileName, + postgresPool: postgres.getPoolStats(), + accountIconReconciliation: accountIconResetReconciler.getHealth(), + clock, + }; + }); try { await realtimeHub.start(); diff --git a/app/game-api/src/services/clockReadiness.ts b/app/game-api/src/services/clockReadiness.ts new file mode 100644 index 00000000..b457245a --- /dev/null +++ b/app/game-api/src/services/clockReadiness.ts @@ -0,0 +1,92 @@ +import { parseGameClockPhase } from '@sammo-ts/common'; + +import type { DatabaseClient } from '../context.js'; + +const safeInteger = (value: bigint, label: string): number => { + const result = Number(value); + if (!Number.isSafeInteger(result)) throw new Error(`${label} is outside the safe integer range.`); + return result; +}; + +export const loadClockReadiness = async (db: DatabaseClient) => { + if (!db.clockProjectionOutbox) { + return { + reconciliationComplete: false, + gameplayEnabled: false, + phase: null, + revision: null, + deadlineGeneration: null, + incompleteOutboxCount: null, + }; + } + const [world, incompleteOutboxCount] = await Promise.all([ + db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { clockPhase: true, clockRevision: true, deadlineGeneration: true }, + }), + db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } }), + ]); + if (!world) { + return { + reconciliationComplete: false, + gameplayEnabled: false, + phase: null, + revision: null, + deadlineGeneration: null, + incompleteOutboxCount, + }; + } + const phase = parseGameClockPhase(world.clockPhase); + return { + reconciliationComplete: phase !== 'RECONCILING' && incompleteOutboxCount === 0, + gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL', + phase, + revision: safeInteger(world.clockRevision, 'clock revision'), + deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'), + incompleteOutboxCount, + }; +}; + +export const loadClockAdminStatus = async (db: DatabaseClient) => { + const readiness = await loadClockReadiness(db); + if (!db.clockSuspension) { + return { ...readiness, latestReconciliation: null }; + } + const latest = await db.clockSuspension.findFirst({ + orderBy: { createdAt: 'desc' }, + include: { + participants: { orderBy: { participantKey: 'asc' } }, + projectionOutbox: { orderBy: { id: 'asc' } }, + }, + }); + if (!latest) return { ...readiness, latestReconciliation: null }; + return { + ...readiness, + latestReconciliation: { + id: latest.id, + source: latest.source, + policy: latest.policy, + status: latest.status, + sourceRevision: safeInteger(latest.sourceRevision, 'source revision'), + targetRevision: safeInteger(latest.targetRevision, 'target revision'), + cutTick: safeInteger(latest.cutTick, 'cut tick'), + alignedTick: latest.alignedTick === null ? null : safeInteger(latest.alignedTick, 'aligned tick'), + participantChecksumBefore: latest.participantChecksumBefore, + participantChecksumAfter: latest.participantChecksumAfter, + participants: latest.participants.map((participant) => ({ + key: participant.participantKey, + policy: participant.policy, + beforeChecksum: participant.beforeChecksum, + afterChecksum: participant.afterChecksum, + affectedCount: participant.affectedCount, + })), + outbox: latest.projectionOutbox.map((entry) => ({ + id: entry.id.toString(), + targetRevision: safeInteger(entry.targetRevision, 'outbox target revision'), + status: entry.status, + attempts: entry.attempts, + lastError: entry.lastError, + })), + }, + }; +}; diff --git a/app/game-api/src/services/gameClock.ts b/app/game-api/src/services/gameClock.ts index d64a103d..d2c592f7 100644 --- a/app/game-api/src/services/gameClock.ts +++ b/app/game-api/src/services/gameClock.ts @@ -1,4 +1,10 @@ -import { GameClock, type GameClockMode } from '@sammo-ts/common'; +import { + GameClock, + inferClockPhase, + parseGameClockPhase, + type GameClockMode, + type GameClockPhase, +} from '@sammo-ts/common'; import type { DatabaseClient } from '../context.js'; @@ -7,6 +13,9 @@ export interface CurrentGameTime { wallNow: Date; tick: number | null; mode: GameClockMode | null; + phase?: GameClockPhase | null; + revision?: number | null; + deadlineGeneration?: number | null; running: boolean; startsAt: Date | null; dateToTick(date: Date): number | null; @@ -19,6 +28,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date wallNow, tick: null, mode: null, + phase: null, + revision: null, + deadlineGeneration: null, running: true, startsAt: null, dateToTick: () => null, @@ -32,6 +44,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date clockMode: true, clockWallAnchor: true, tickSeconds: true, + clockPhase: true, + clockRevision: true, + deadlineGeneration: true, }, }); if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) { @@ -40,32 +55,53 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date wallNow, tick: null, mode: null, + phase: null, + revision: null, + deadlineGeneration: null, running: true, startsAt: null, dateToTick: () => null, }; } const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime'; + // During the dual-read migration an older profile (or a rolling-deploy + // fixture) can lack clock_phase. Preserve the existing future-anchor + // PREOPEN contract until every profile has the authoritative column. + const phase = state.clockPhase + ? parseGameClockPhase(state.clockPhase) + : mode === 'realtime' && wallNow.getTime() < state.clockWallAnchor.getTime() + ? 'PREOPEN' + : inferClockPhase(mode); const storedTick = Number(state.clockTick); if (!Number.isSafeInteger(storedTick)) { throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`); } + const revision = Number(state.clockRevision ?? 1n); + const deadlineGeneration = Number(state.deadlineGeneration ?? 1n); + if (!Number.isSafeInteger(revision) || !Number.isSafeInteger(deadlineGeneration)) { + throw new Error('world_state clock revision or deadline generation is outside the safe integer range.'); + } const clock = new GameClock({ baseTime: state.clockBaseTime, tick: storedTick, mode, wallAnchor: state.clockWallAnchor, turnSeconds: state.tickSeconds, + phase, + revision, }); const tick = clock.nowTick(wallNow); - const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime(); + const running = phase === 'RUNNING' && mode === 'realtime'; return { now: clock.tickToDate(tick), wallNow, tick, mode, + phase, + revision, + deadlineGeneration, running, - startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null, + startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null, dateToTick: (date) => clock.dateToTick(date), }; }; diff --git a/app/game-api/src/services/redisClockFence.ts b/app/game-api/src/services/redisClockFence.ts new file mode 100644 index 00000000..0b192c90 --- /dev/null +++ b/app/game-api/src/services/redisClockFence.ts @@ -0,0 +1,84 @@ +import type { CurrentGameTime } from './gameClock.js'; +import type { GameClockPhase } from '@sammo-ts/common'; + +interface ClockFenceRedis { + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; +} + +const BOOTSTRAP_CLOCK_FENCE_SCRIPT = ` +local revision = redis.call('GET', KEYS[1]) +local generation = redis.call('GET', KEYS[2]) +local phase = redis.call('GET', KEYS[3]) +if not revision and not generation and not phase then + redis.call('SET', KEYS[1], ARGV[1]) + redis.call('SET', KEYS[2], ARGV[2]) + redis.call('SET', KEYS[3], ARGV[3]) + return 1 +end +if revision == ARGV[1] and generation == ARGV[2] and phase == ARGV[3] then + return 2 +end +return 0 +`; + +export interface ActiveRedisClockFence { + activeRevisionKey: string; + deadlineGenerationKey: string; + phaseKey: string; + revision: number; + generation: number; + phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED'; +} + +type MutableProjectionPhase = ActiveRedisClockFence['phase']; + +const ensureRedisClockFence = async ( + redis: ClockFenceRedis, + profileName: string, + gameTime: CurrentGameTime, + allowedPhases: readonly GameClockPhase[] +): Promise => { + if ( + !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), 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/keys.ts b/app/game-api/src/tournament/keys.ts index e560483d..0938a773 100644 --- a/app/game-api/src/tournament/keys.ts +++ b/app/game-api/src/tournament/keys.ts @@ -8,6 +8,9 @@ export interface TournamentKeys { sourceRevisionKey: string; sourceRevisionChannel: string; realtimeEventChannel: string; + activeClockRevisionKey: string; + deadlineGenerationKey: string; + clockPhaseKey: string; } export const buildTournamentKeys = (profileName: string): TournamentKeys => ({ @@ -18,4 +21,7 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({ sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, realtimeEventChannel: buildGameEventChannel(profileName), + activeClockRevisionKey: `sammo:${profileName}:clock:active-revision`, + deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`, + clockPhaseKey: `sammo:${profileName}:clock:phase`, }); diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index ba7d656d..6ea917f4 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common'; +import { performance } from 'node:perf_hooks'; +import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common'; import { z } from 'zod'; import type { TournamentKeys } from './keys.js'; @@ -37,8 +38,12 @@ const zTournamentState = z openMonth: z.number().int(), termSeconds: z.number(), nextAt: z.string(), + nextTick: z.number().int().safe().optional(), + clockRevision: z.number().int().positive().safe().optional(), + deadlineGeneration: z.number().int().positive().safe().optional(), bettingId: z.number().int().optional(), bettingCloseAt: z.string().optional(), + bettingCloseTick: z.number().int().safe().optional(), winnerId: z.number().int().optional(), bettingSettled: z.boolean().optional(), rewardSettled: z.boolean().optional(), @@ -131,12 +136,62 @@ const parseProjection = (raw: string | null, key: string, schema: z.ZodType { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`Tournament ${field} is not a valid instant.`); + } + const tick = context.dateToTick(date); + if (tick === null || !Number.isSafeInteger(tick)) { + throw new Error(`Tournament ${field} cannot be represented in the active game clock.`); + } + return tick; +}; + +export const stampTournamentClock = (state: TournamentState, context: TournamentClockContext): TournamentState => ({ + ...state, + nextTick: parseDeadlineTick(state.nextAt, context, 'nextAt'), + clockRevision: context.revision, + deadlineGeneration: context.deadlineGeneration, + ...(state.bettingCloseAt + ? { bettingCloseTick: parseDeadlineTick(state.bettingCloseAt, context, 'bettingCloseAt') } + : { bettingCloseTick: undefined }), +}); + +const toClockFence = (keys: TournamentKeys, context: TournamentClockContext): TournamentClockFence => ({ + activeRevisionKey: keys.activeClockRevisionKey, + deadlineGenerationKey: keys.deadlineGenerationKey, + phaseKey: keys.clockPhaseKey, + revision: context.revision, + deadlineGeneration: context.deadlineGeneration, + phase: context.phase, +}); + export class TournamentStore { + private clockContext: TournamentClockContext | null = null; + constructor( private readonly redis: RedisClientLike, private readonly keys: TournamentKeys ) {} + async withClockContext(context: TournamentClockContext, operation: () => Promise): Promise { + const previous = this.clockContext; + this.clockContext = context; + try { + return await operation(); + } finally { + this.clockContext = previous; + } + } + async withMutationLock(operation: () => Promise, timeoutMs = 2_000): Promise { if (!this.redis.del) { return operation(); @@ -144,8 +199,8 @@ export class TournamentStore { const lockKey = `${this.keys.stateKey}:mutation-lock`; const token = randomUUID(); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 }); if (acquired) { try { @@ -174,11 +229,15 @@ export class TournamentStore { } private async writeWithSourceRevision(key: string, value: unknown): Promise { - return writeTournamentProjection(this.redis, this.keys, [{ key, value }]); + const fence = this.clockContext ? toClockFence(this.keys, this.clockContext) : undefined; + return writeTournamentProjection(this.redis, this.keys, [{ key, value }], fence); } async setState(state: TournamentState): Promise { - return this.writeWithSourceRevision(this.keys.stateKey, state); + return this.writeWithSourceRevision( + this.keys.stateKey, + this.clockContext ? stampTournamentClock(state, this.clockContext) : state + ); } async getParticipants(): Promise { diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index 3efde50b..0511bb3c 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -9,8 +9,12 @@ export interface TournamentState { openMonth: number; termSeconds: number; nextAt: string; + nextTick?: number; + clockRevision?: number; + deadlineGeneration?: number; bettingId?: number; bettingCloseAt?: string; + bettingCloseTick?: number; winnerId?: number; bettingSettled?: boolean; rewardSettled?: boolean; diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 5c41f890..2ce052be 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -13,9 +13,10 @@ import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js'; import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js'; import { loadCurrentGameTime } from '../services/gameClock.js'; import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js'; +import { ensureActiveRedisClockFence } from '../services/redisClockFence.js'; import type { TurnDaemonTransport } from '../daemon/transport.js'; import { buildTournamentKeys } from './keys.js'; -import { TournamentStore } from './store.js'; +import { TournamentStore, stampTournamentClock, type TournamentClockContext } from './store.js'; import type { TournamentMatchEntry, TournamentState } from './types.js'; import { applyGroupMatch, @@ -595,41 +596,63 @@ export const processTournamentTick = async (options: { prisma: GamePrismaClient; daemonTransport: TurnDaemonTransport; now?: () => number; + clockContext?: TournamentClockContext; }): Promise => { const { store, prisma, daemonTransport } = options; const now = options.now ?? Date.now; let processedState: TournamentState | null = null; - await store.withMutationLock(async () => { - const state = await store.getState(); - if (!state || (!state.auto && !needsSettlement(state))) { - return; - } - const nextAt = new Date(state.nextAt).getTime(); - if (state.auto && Number.isFinite(nextAt) && nextAt > now()) { - return; - } + const processWithLock = async (): Promise => + store.withMutationLock(async () => { + const state = await store.getState(); + if (!state || (!state.auto && !needsSettlement(state))) { + return; + } + if ( + options.clockContext && + (state.clockRevision !== options.clockContext.revision || + state.deadlineGeneration !== options.clockContext.deadlineGeneration) + ) { + throw new Error('Tournament state does not match the active clock revision.'); + } + const nextAt = new Date(state.nextAt).getTime(); + const notDue = options.clockContext + ? !Number.isSafeInteger(state.nextTick) || + state.nextTick! > options.clockContext.dateToTick(new Date(now()))! + : Number.isFinite(nextAt) && nextAt > now(); + if (state.auto && notDue) { + if (options.clockContext && !Number.isSafeInteger(state.nextTick)) { + throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.'); + } + return; + } - if (needsSettlement(state)) { - processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state; - return; - } + if (needsSettlement(state)) { + processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state; + return; + } - const worldState = await prisma.worldState.findFirst(); - const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; - let nextState = state; - if (isBattleStage(state.stage)) { - nextState = await applyBattle(store, state, String(baseSeed), daemonTransport); - } else if (isPreBattleStage(state.stage)) { - nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now); - } - processedState = - (await settleTournamentOutcome({ - store, - daemonTransport, - state: nextState, - })) ?? nextState; - }); + const worldState = await prisma.worldState.findFirst(); + const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; + let nextState = state; + if (isBattleStage(state.stage)) { + nextState = await applyBattle(store, state, String(baseSeed), daemonTransport); + } else if (isPreBattleStage(state.stage)) { + nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now); + } + processedState = + (await settleTournamentOutcome({ + store, + daemonTransport, + state: nextState, + })) ?? nextState; + }); + + if (options.clockContext) { + await store.withClockContext(options.clockContext, processWithLock); + } else { + await processWithLock(); + } return processedState; }; @@ -656,16 +679,60 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {}) try { while (!control.signal.aborted) { - const state = await store.getState(); + let state = await store.getState(); if (!state || (!state.auto && !needsSettlement(state))) { await waitForWorkerPoll(control.signal, config.tournamentPollMs); continue; } + const gameTime = await loadCurrentGameTime(postgres.prisma); + if (gameTime.phase && gameTime.phase !== 'RUNNING') { + await waitForWorkerPoll(control.signal, config.tournamentPollMs); + continue; + } + const clockFence = gameTime.phase + ? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime) + : null; + if (gameTime.phase && !clockFence) { + await waitForWorkerPoll(control.signal, config.tournamentPollMs); + continue; + } + const clockContext: TournamentClockContext | undefined = clockFence + ? { + phase: 'RUNNING', + revision: clockFence.revision, + deadlineGeneration: clockFence.generation, + dateToTick: gameTime.dateToTick, + } + : undefined; + if ( + clockContext && + (!Number.isSafeInteger(state.nextTick) || + state.clockRevision === undefined || + state.deadlineGeneration === undefined) + ) { + state = stampTournamentClock(state, clockContext); + await store.withClockContext(clockContext, () => store.setState(state!)); + } + if ( + clockContext && + (state.clockRevision !== clockContext.revision || + state.deadlineGeneration !== clockContext.deadlineGeneration) + ) { + await waitForWorkerPoll(control.signal, config.tournamentPollMs); + continue; + } + const nextAt = new Date(state.nextAt).getTime(); - const gameNow = (await loadCurrentGameTime(postgres.prisma)).now.getTime(); - if (state.auto && Number.isFinite(nextAt) && nextAt > gameNow) { - await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - gameNow)); + const gameNow = gameTime.now.getTime(); + const notDue = clockContext + ? state.nextTick! > gameTime.tick! + : Number.isFinite(nextAt) && nextAt > gameNow; + if (state.auto && notDue) { + await waitForWorkerPoll( + control.signal, + Math.min(config.tournamentPollMs, Math.max(1, nextAt - gameNow)) + ); continue; } @@ -675,6 +742,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {}) prisma: postgres.prisma, daemonTransport, now: () => gameNow, + clockContext, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -700,7 +768,11 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {}) lastError: message, lastErrorAt: failedAt, }; - await store.setState(nextState); + if (clockContext) { + await store.withClockContext(clockContext, () => store.setState(nextState)); + } else { + await store.setState(nextState); + } } await waitForWorkerPoll(control.signal, config.tournamentPollMs); diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 816d9894..da8a7990 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => { 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/accountIconResetReconciler.integration.test.ts b/app/game-api/test/accountIconResetReconciler.integration.test.ts index 912d011f..56b0ec82 100644 --- a/app/game-api/test/accountIconResetReconciler.integration.test.ts +++ b/app/game-api/test/accountIconResetReconciler.integration.test.ts @@ -56,6 +56,14 @@ const lifecycleState: TurnWorldState = { currentMonth: 1, tickSeconds: 600, lastTurnTime: new Date('2026-07-31T10:00:00.000Z'), + clockBaseTime: new Date('2026-07-31T10:00:00.000Z'), + clockTick: 0, + clockMode: 'manual', + clockWallAnchor: new Date('2026-07-31T10:00:00.000Z'), + lastTurnTick: 0, + clockPhase: 'MANUAL', + clockRevision: 1, + deadlineGeneration: 1, meta: { killturn: 24, scenarioMeta }, }; const lifecycleGeneral: TurnGeneral = { @@ -121,6 +129,14 @@ integration('account icon reset reconciliation PostgreSQL queue', () => { currentYear: lifecycleState.currentYear, currentMonth: lifecycleState.currentMonth, tickSeconds: lifecycleState.tickSeconds, + clockBaseTime: lifecycleState.clockBaseTime, + clockTick: BigInt(lifecycleState.clockTick ?? 0), + clockMode: lifecycleState.clockMode ?? 'manual', + clockWallAnchor: lifecycleState.clockWallAnchor, + lastTurnTick: BigInt(lifecycleState.lastTurnTick ?? 0), + clockPhase: lifecycleState.clockPhase ?? 'MANUAL', + clockRevision: BigInt(lifecycleState.clockRevision ?? 1), + deadlineGeneration: BigInt(lifecycleState.deadlineGeneration ?? 1), config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, meta: lifecycleState.meta as GamePrisma.InputJsonValue, }, 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 a93c97a1..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'); @@ -299,6 +317,14 @@ liveDescribe('auction worker durable recovery', () => { currentMonth: 1, tickSeconds: 600, lastTurnTime: new Date('2026-07-31T11:00:00.000Z'), + clockBaseTime: new Date('2026-07-31T11:00:00.000Z'), + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-07-31T11:00:00.000Z'), + lastTurnTick: 0, + clockPhase: 'RUNNING', + clockRevision: 1, + deadlineGeneration: 1, meta: { killturn: 24, scenarioMeta }, }; const buildGeneral = (options: { @@ -381,6 +407,14 @@ liveDescribe('auction worker durable recovery', () => { currentYear: state.currentYear, currentMonth: state.currentMonth, tickSeconds: state.tickSeconds, + clockBaseTime: state.clockBaseTime, + clockTick: BigInt(state.clockTick ?? 0), + clockMode: state.clockMode ?? 'realtime', + clockWallAnchor: state.clockWallAnchor, + lastTurnTick: BigInt(state.lastTurnTick ?? 0), + clockPhase: state.clockPhase ?? 'RUNNING', + clockRevision: BigInt(state.clockRevision ?? 1), + deadlineGeneration: BigInt(state.deadlineGeneration ?? 1), config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue, }, @@ -415,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); @@ -425,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 }); @@ -493,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => { detail: { remainCloseDateExtensionCnt: 1 }, status: 'OPEN', closeAt: logicalPastCloseAt, + closeTick: 0n, }, }); extensionAuctionId = extensionAuction.id; @@ -505,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 }, }, }); @@ -516,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 a3ae0b86..cfae3ee9 100644 --- a/app/game-api/test/auctionWorker.test.ts +++ b/app/game-api/test/auctionWorker.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { GamePrismaClient } from '@sammo-ts/infra'; -import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js'; +import { popDueAuctionIds, processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js'; import { resolveAuctionSeedScore } from '../src/auction/scheduler.js'; const buildRedis = () => ({ @@ -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: { @@ -55,6 +55,38 @@ const buildDb = (options: { }; describe('auction worker clock-shift race', () => { + it('uses one Redis script for revision, generation, phase, due-read, and removal', async () => { + const redis = { ...buildRedis(), eval: vi.fn(async () => ['7', '9']) }; + await expect( + popDueAuctionIds(redis, 'auction-timer', 123_456, 100, { + activeRevisionKey: 'clock-revision', + deadlineGenerationKey: 'deadline-generation', + phaseKey: 'clock-phase', + revision: 4, + generation: 8, + }) + ).resolves.toEqual(['7', '9']); + expect(redis.eval).toHaveBeenCalledWith(expect.stringContaining('ZRANGEBYSCORE'), { + keys: ['auction-timer', 'clock-revision', 'deadline-generation', 'clock-phase'], + arguments: ['4', '8', '123456', '100'], + }); + expect(redis.zRangeByScore).not.toHaveBeenCalled(); + expect(redis.zRem).not.toHaveBeenCalled(); + }); + + it('returns no due member when the atomic Redis clock fence rejects the pop', async () => { + const redis = { ...buildRedis(), eval: vi.fn(async () => ['__CLOCK_FENCE__']) }; + await expect( + popDueAuctionIds(redis, 'auction-timer', 123_456, 100, { + activeRevisionKey: 'clock-revision', + deadlineGenerationKey: 'deadline-generation', + phaseKey: 'clock-phase', + revision: 4, + generation: 8, + }) + ).resolves.toEqual([]); + }); + it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => { const now = new Date('2026-07-30T12:00:00.000Z'); const time = { @@ -201,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(); }); @@ -235,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(); @@ -247,6 +280,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs, + nowTick: 72_000_000, }) ).resolves.toBe('PENDING'); @@ -261,6 +295,7 @@ describe('auction worker clock-shift race', () => { requestId, auctionId: 7, expectedCloseAt: closeAt.toISOString(), + expectedCloseTick: 72_000_000, }, }, }); @@ -318,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'); @@ -330,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 }, @@ -344,6 +380,7 @@ describe('auction worker clock-shift race', () => { requestId, auctionId: 7, expectedCloseAt: closeAt.toISOString(), + expectedCloseTick: 72_000_000, }, status: 'PENDING', result: null, @@ -359,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(); @@ -379,6 +417,7 @@ describe('auction worker clock-shift race', () => { historyKey: 'history', id: '7', nowMs: logicalNowMs, + nowTick: 72_000_000, historyNowMs: operationalNowMs, }); @@ -388,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, }; @@ -411,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'); @@ -423,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, @@ -433,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, }, @@ -448,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'); @@ -461,6 +502,7 @@ describe('auction worker clock-shift race', () => { requestId: retryRequestId, auctionId: 7, expectedCloseAt: closeAt.toISOString(), + expectedCloseTick: 72_000_000, }, }, }); @@ -468,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 }, }, @@ -495,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'); @@ -508,6 +555,7 @@ describe('auction worker clock-shift race', () => { requestId, auctionId: 7, expectedCloseAt: closeAt.toISOString(), + expectedCloseTick: 72_000_000, }, }, }); @@ -527,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/clockReadiness.test.ts b/app/game-api/test/clockReadiness.test.ts new file mode 100644 index 00000000..e910b5f8 --- /dev/null +++ b/app/game-api/test/clockReadiness.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DatabaseClient } from '../src/context.js'; +import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js'; + +describe('clock reconciliation readiness', () => { + it('fails closed when the reconciliation schema is not available', async () => { + const db = {} as DatabaseClient; + await expect(loadClockReadiness(db)).resolves.toEqual({ + reconciliationComplete: false, + gameplayEnabled: false, + phase: null, + revision: null, + deadlineGeneration: null, + incompleteOutboxCount: null, + }); + }); + + it('blocks readiness for RECONCILING or incomplete outbox state', async () => { + const db = { + worldState: { + findFirst: vi.fn(async () => ({ + clockPhase: 'RECONCILING', + clockRevision: 9n, + deadlineGeneration: 4n, + })), + }, + clockProjectionOutbox: { count: vi.fn(async () => 1) }, + } as unknown as DatabaseClient; + await expect(loadClockReadiness(db)).resolves.toMatchObject({ + reconciliationComplete: false, + gameplayEnabled: false, + phase: 'RECONCILING', + revision: 9, + deadlineGeneration: 4, + incompleteOutboxCount: 1, + }); + }); + + it('exposes participant checksums and incomplete outbox detail to admins', async () => { + const db = { + worldState: { + findFirst: vi.fn(async () => ({ + clockPhase: 'RUNNING', + clockRevision: 3n, + deadlineGeneration: 2n, + })), + }, + clockProjectionOutbox: { count: vi.fn(async () => 0) }, + clockSuspension: { + findFirst: vi.fn(async () => ({ + id: 'maintenance-1', + source: 'MAINTENANCE', + policy: 'EXACT', + status: 'APPLIED', + sourceRevision: 2n, + targetRevision: 3n, + cutTick: 100n, + alignedTick: 130n, + participantChecksumBefore: 'before-all', + participantChecksumAfter: 'after-all', + participants: [ + { + participantKey: 'general-turn', + policy: 'SHIFT', + beforeChecksum: 'before', + afterChecksum: 'after', + affectedCount: 2, + }, + ], + projectionOutbox: [{ id: 8n, targetRevision: 3n, status: 'APPLIED', attempts: 1, lastError: null }], + })), + }, + } as unknown as DatabaseClient; + + await expect(loadClockAdminStatus(db)).resolves.toMatchObject({ + reconciliationComplete: true, + latestReconciliation: { + id: 'maintenance-1', + participantChecksumBefore: 'before-all', + participantChecksumAfter: 'after-all', + participants: [{ key: 'general-turn', policy: 'SHIFT', affectedCount: 2 }], + outbox: [{ id: '8', status: 'APPLIED' }], + }, + }); + }); +}); 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/gameClock.test.ts b/app/game-api/test/gameClock.test.ts index 8258ece2..8017409e 100644 --- a/app/game-api/test/gameClock.test.ts +++ b/app/game-api/test/gameClock.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { DatabaseClient } from '../src/context.js'; import { loadCurrentGameTime } from '../src/services/gameClock.js'; -const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient => +const buildDatabase = ( + mode: 'realtime' | 'manual' = 'realtime', + phase: 'PREOPEN' | 'RUNNING' | 'MANUAL' = mode === 'manual' ? 'MANUAL' : 'PREOPEN' +): DatabaseClient => ({ worldState: { findFirst: vi.fn(async () => ({ @@ -12,6 +15,9 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient clockMode: mode, clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'), tickSeconds: 600, + clockPhase: phase, + clockRevision: 1n, + deadlineGeneration: 1n, })), }, }) as unknown as DatabaseClient; @@ -26,11 +32,15 @@ describe('current game time projection', () => { wallNow: new Date('2026-08-21T10:30:00.000Z'), tick: -108_000_000, mode: 'realtime', + phase: 'PREOPEN', running: false, startsAt: new Date('2026-08-21T11:00:00.000Z'), }); - const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z')); + const opened = await loadCurrentGameTime( + buildDatabase('realtime', 'RUNNING'), + new Date('2026-08-21T11:00:05.000Z') + ); expect(opened).toMatchObject({ now: new Date('2026-08-21T11:00:05.000Z'), tick: 300_000, 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/inheritOwnerMessages.integration.test.ts b/app/game-api/test/inheritOwnerMessages.integration.test.ts index 495b54ae..d135c394 100644 --- a/app/game-api/test/inheritOwnerMessages.integration.test.ts +++ b/app/game-api/test/inheritOwnerMessages.integration.test.ts @@ -64,6 +64,14 @@ const state: TurnWorldState = { currentMonth: 4, tickSeconds: 600, lastTurnTime: new Date('2026-08-19T00:00:00.000Z'), + clockBaseTime: new Date('2026-08-19T00:00:00.000Z'), + clockTick: 0, + clockMode: 'manual', + clockWallAnchor: new Date('2026-08-19T00:00:00.000Z'), + lastTurnTick: 0, + clockPhase: 'MANUAL', + clockRevision: 1, + deadlineGeneration: 1, meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta }, }; @@ -176,6 +184,14 @@ integration('inherit owner lookup private messages', () => { currentYear: 200, currentMonth: 4, tickSeconds: 600, + clockBaseTime: state.clockBaseTime, + clockTick: BigInt(state.clockTick ?? 0), + clockMode: state.clockMode ?? 'manual', + clockWallAnchor: state.clockWallAnchor, + lastTurnTick: BigInt(state.lastTurnTick ?? 0), + clockPhase: state.clockPhase ?? 'MANUAL', + clockRevision: BigInt(state.clockRevision ?? 1), + deadlineGeneration: BigInt(state.deadlineGeneration ?? 1), config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue, }, diff --git a/app/game-api/test/inputEventBoundary.integration.test.ts b/app/game-api/test/inputEventBoundary.integration.test.ts index d1dfb8cf..ce3c1915 100644 --- a/app/game-api/test/inputEventBoundary.integration.test.ts +++ b/app/game-api/test/inputEventBoundary.integration.test.ts @@ -18,6 +18,7 @@ import { const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const journalGeneralIds = [9_980_081, 9_980_082] as const; +const clockScenarioCode = 'input-event-boundary'; const journalBoundaryRouter = router({ mutate: procedure @@ -56,6 +57,21 @@ integration('API input event boundary', () => { await db.readModelRevision.deleteMany({ where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } }, }); + await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } }); + const base = new Date('2099-09-03T00:00:00.000Z'); + await db.worldState.create({ + data: { + scenarioCode: clockScenarioCode, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + clockBaseTime: base, + clockTick: 0n, + clockMode: 'realtime', + clockWallAnchor: base, + clockPhase: 'RUNNING', + }, + }); }); afterAll(async () => { @@ -68,6 +84,7 @@ integration('API input event boundary', () => { await db.readModelRevision.deleteMany({ where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } }, }); + await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } }); await close?.(); }); @@ -518,6 +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).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 4046fb47..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 }) => { @@ -28,6 +36,9 @@ const createContext = (payload: unknown = {}) => { status: 'PENDING', result: null, attempts: 0, + acceptedGameTick: 100n, + acceptedClockRevision: 3n, + acceptedDeadlineGeneration: 2n, }, ]; } @@ -36,8 +47,19 @@ const createContext = (payload: unknown = {}) => { }); const transaction = { $queryRaw: queryRaw, - $executeRaw: vi.fn(async () => { - order.push('accepted'); + $executeRaw: vi.fn(async (query: { sql?: string }) => { + 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) => { @@ -88,6 +110,7 @@ describe('API input-event change journal boundary', () => { expect(fixture.order).toEqual([ 'transaction-begin', + 'clock-fence', 'accepted', 'locked', 'processing', @@ -112,6 +135,7 @@ describe('API input-event change journal boundary', () => { expect(fixture.order).toEqual([ 'transaction-begin', + 'clock-fence', 'accepted', 'locked', 'processing', @@ -126,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..1436134f 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 { createGamePostgresConnector, persistMessageEnvelope, 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); @@ -23,6 +23,51 @@ integration('message deletion tombstone persistence', () => { afterAll(async () => close?.()); + it('persists an ordinary wall-time envelope without creating a game action', async () => { + const rollback = new Error('rollback ordinary message envelope fixture'); + await expect( + db.$transaction(async (transaction) => { + const target = { + generalId: 7, + generalName: '보낸이', + nationId: 0, + nationName: '재야', + color: '#000000', + icon: '', + }; + const id = await persistMessageEnvelope( + transaction, + { + mailbox: 9999, + msgType: 'public', + srcId: target.generalId, + destId: 9999, + time: new Date('0200-01-01T00:00:00.000Z'), + validUntil: new Date('9999-12-31T00:00:00.000Z'), + payload: { + src: target, + dest: target, + text: '일반 메시지는 WALL_TIME envelope만 저장한다.', + option: {}, + }, + }, + null + ); + + const message = await transaction.message.findUniqueOrThrow({ + where: { id }, + include: { action: true }, + }); + expect(message.createdAtWall).toBeInstanceOf(Date); + expect(message.deleteUntilWall.getTime() - message.createdAtWall.getTime()).toBe(5 * 60_000); + expect(message.occurredGameTick).toBeNull(); + expect(message.action).toBeNull(); + + throw rollback; + }) + ).rejects.toBe(rollback); + }); + it('keeps sender and receiver rows readable while replacing their bodies', async () => { const rollback = new Error('rollback message tombstone fixture'); await expect( @@ -70,6 +115,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 +127,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..cde1f34a 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(); }); @@ -713,8 +801,11 @@ describe('messages router missing-flow compatibility', () => { action, reason: 'success', })); + const transaction = vi.fn(async () => { + throw new Error('ENGINE message response must not enter the API input-event transaction.'); + }); const { caller } = buildContext( - { $queryRaw: vi.fn(async () => [messageRow]) }, + { $queryRaw: vi.fn(async () => [messageRow]), $transaction: transaction }, { turnDaemon: { requestCommand } } ); @@ -734,6 +825,7 @@ describe('messages router missing-flow compatibility', () => { response: true, }) ); + expect(transaction).not.toHaveBeenCalled(); } ); @@ -864,8 +956,18 @@ 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 requestCommand = vi.fn(async (command: { type: string; generalId: number; messageId: number }) => ({ + type: 'syncDiplomaticResponse' as const, + ok: true, + generalId: command.generalId, + messageId: command.messageId, + nations: 2, + diplomacy: 2, + cities: 0, + })); const { caller } = buildContext( { general: { @@ -941,13 +1043,22 @@ 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 } + { changeJournal, turnDaemon: { requestCommand } } ); return { caller, @@ -960,6 +1071,7 @@ describe('messages router missing-flow compatibility', () => { messageUpdateMany, cityUpdate, changeJournal, + requestCommand, }; }; @@ -973,6 +1085,14 @@ describe('messages router missing-flow compatibility', () => { }); expect(result).toEqual({ result: true, reason: 'success' }); + expect(setup.requestCommand).toHaveBeenCalledWith({ + type: 'syncDiplomaticResponse', + userId: auth.user.id, + generalId: setup.actor.id, + messageId: 31, + nationIds: [1, 2], + cityIds: [], + }); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); expect(setup.nationUpdate).toHaveBeenCalledWith( expect.objectContaining({ @@ -987,7 +1107,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 b4635eec..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'; @@ -30,11 +30,31 @@ class MemoryRedis { } async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise { - const [valueKey, revisionKey] = options.keys; - const [value] = options.arguments; + if (options.keys.length === 3 && options.keys[0]?.endsWith(':clock:active-revision')) { + const current = options.keys.map((key) => this.values.get(key)); + if (current.every((value) => value === undefined)) { + options.keys.forEach((key, index) => this.values.set(key, options.arguments[index]!)); + return '1'; + } + return current.every((value, index) => value === options.arguments[index]) ? '2' : '0'; + } + const fenced = options.keys.at(-1)?.endsWith(':clock:phase') === true; + const writeCount = options.keys.length - (fenced ? 4 : 1); + if (fenced) { + const clockKeys = options.keys.slice(-3); + const expected = options.arguments.slice(-3); + if (!clockKeys.every((key, index) => this.values.get(key) === expected[index])) { + return '__CLOCK_FENCE__'; + } + } + const valueKey = options.keys[0]; + const revisionKey = options.keys[writeCount]; + const value = options.arguments[0]; if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments'); const revision = Number(this.values.get(revisionKey) ?? '0') + 1; - this.values.set(valueKey, value); + for (let index = 0; index < writeCount; index += 1) { + this.values.set(options.keys[index]!, options.arguments[index]!); + } this.values.set(revisionKey, String(revision)); return String(revision); } @@ -131,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: { @@ -144,12 +166,21 @@ const buildContext = (options: { }, worldState: { findFirst: async () => ({ + clockBaseTime: new Date('2026-01-01T00:00:00.000Z'), + clockTick: 0n, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'), + clockPhase: options.clockPhase ?? 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, + tickSeconds: 60, config: { const: { develCost: options.develCost ?? 200 } }, ...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }), }), }, } as unknown as DatabaseClient; return { + requestId: options.requestId, db, redis: options.redis as unknown as RedisConnector['client'], turnDaemon: options.transport, @@ -341,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(); @@ -394,7 +502,10 @@ describe('tournament router permissions and mutations', () => { roles: ['admin.tournament:che:default'], }) ); - await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true }); + await expect(adminCaller.tournament.getAdminStatus()).resolves.toMatchObject({ + ok: true, + clock: { reconciliationComplete: false, latestReconciliation: null }, + }); }); it('applies the admin role boundary to every tournament mutation', async () => { @@ -479,9 +590,7 @@ describe('tournament router permissions and mutations', () => { ]) ).resolves.toEqual({ ok: true, count: 1 }); await expect( - caller.tournament.setBettingEntries([ - { generalId: general.id, targetId: rival.id, amount: 100 }, - ]) + caller.tournament.setBettingEntries([{ generalId: general.id, targetId: rival.id, amount: 100 }]) ).resolves.toEqual({ ok: true, count: 1 }); await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({ ok: true, diff --git a/app/game-api/test/tournamentStoreRevision.integration.test.ts b/app/game-api/test/tournamentStoreRevision.integration.test.ts index 5acf6dd6..8ecb1448 100644 --- a/app/game-api/test/tournamentStoreRevision.integration.test.ts +++ b/app/game-api/test/tournamentStoreRevision.integration.test.ts @@ -41,6 +41,9 @@ integration('TournamentStore Redis source revision', () => { keys.matchesKey, keys.bettingKey, keys.sourceRevisionKey, + keys.activeClockRevisionKey, + keys.deadlineGenerationKey, + keys.clockPhaseKey, ]); if (subscriber) { await subscriber.client.unsubscribe(keys.sourceRevisionChannel); @@ -124,4 +127,48 @@ integration('TournamentStore Redis source revision', () => { }), ]); }); + + it('dual-writes deadline ticks and rejects a Redis clock revision race atomically', async () => { + const store = new TournamentStore(connector.client, keys); + await Promise.all([ + connector.client.set(keys.activeClockRevisionKey, '7'), + connector.client.set(keys.deadlineGenerationKey, '3'), + connector.client.set(keys.clockPhaseKey, 'RUNNING'), + ]); + const clockContext = { + phase: 'RUNNING' as const, + revision: 7, + deadlineGeneration: 3, + dateToTick: (date: Date) => Math.trunc(date.getTime() / 1_000), + }; + const nextAt = '2026-09-03T10:00:00.000Z'; + await store.withClockContext(clockContext, () => + store.setState({ + stage: 6, + phase: 0, + type: 0, + auto: true, + openYear: 200, + openMonth: 1, + termSeconds: 10, + nextAt, + bettingCloseAt: '2026-09-03T09:59:50.000Z', + }) + ); + await expect(store.getState()).resolves.toMatchObject({ + nextTick: Math.trunc(new Date(nextAt).getTime() / 1_000), + bettingCloseTick: Math.trunc(new Date('2026-09-03T09:59:50.000Z').getTime() / 1_000), + clockRevision: 7, + deadlineGeneration: 3, + }); + + const beforeRevision = await store.getSourceRevision(); + await connector.client.set(keys.activeClockRevisionKey, '8'); + await expect( + store.withClockContext(clockContext, () => + store.setMatches([{ id: 99, stage: 7, roundIndex: 0, attackerId: 1, defenderId: 2 }]) + ) + ).rejects.toThrow('clock revision fence failed'); + await expect(store.getSourceRevision()).resolves.toBe(beforeRevision); + }); }); 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/package.json b/app/game-engine/package.json index 2201ab88..d2edfd97 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -42,6 +42,14 @@ "types": "./dist/turn/databaseHooks.d.ts", "default": "./dist/turn/databaseHooks.js" }, + "./turn/clockReconciliation.js": { + "types": "./dist/turn/clockReconciliation.d.ts", + "default": "./dist/turn/clockReconciliation.js" + }, + "./turn/clockProjectionOutbox.js": { + "types": "./dist/turn/clockProjectionOutbox.d.ts", + "default": "./dist/turn/clockProjectionOutbox.js" + }, "./turn/inMemoryWorld.js": { "types": "./dist/turn/inMemoryWorld.d.ts", "default": "./dist/turn/inMemoryWorld.js" diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index 9c6a6586..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,8 +268,15 @@ export const createAuctionBidder = async (options: { reason: '경매가 종료되었습니다.', }; } - const processingNow = world.getGameNow(new Date()); - const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, command.acceptedGameTick); + const convertedProcessingTick = Reflect.get(command, 'processingGameTick'); + 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', @@ -503,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' @@ -529,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} @@ -549,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} @@ -580,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( @@ -704,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/index.ts b/app/game-engine/src/index.ts index aeab1633..5bf90c79 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -20,6 +20,8 @@ export * from './turn/engineStateManager.js'; export * from './turn/inMemoryStateStore.js'; export * from './turn/inMemoryTurnProcessor.js'; export * from './turn/databaseHooks.js'; +export * from './turn/clockReconciliation.js'; +export * from './turn/clockProjectionOutbox.js'; export * from './turn/joinCreateGeneralService.js'; export * from './turn/npcPossessionService.js'; export * from './turn/selectPoolService.js'; diff --git a/app/game-engine/src/lifecycle/databaseCommandQueue.ts b/app/game-engine/src/lifecycle/databaseCommandQueue.ts index 6e4d8343..c4936c12 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,30 +87,50 @@ 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 maintenanceSuspended = + world?.clockPhase === 'SUSPENDED' && + Boolean( + await transaction.clockSuspension.findFirst({ + where: { + status: 'SUSPENDED', + source: 'MAINTENANCE', + sourceRevision: world.clockRevision, + }, + select: { id: true }, + }) + ); const rows = await transaction.$queryRaw< Array<{ sequence: bigint; @@ -115,6 +138,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T eventType: string; payload: unknown; createdAt: Date; + acceptedGameTick: bigint | null; + acceptedClockRevision: bigint | null; + acceptedDeadlineGeneration: bigint | null; }> >(GamePrisma.sql` SELECT @@ -122,10 +148,71 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T "request_id" AS "requestId", "event_type" AS "eventType", "payload", - "created_at" AS "createdAt" + "created_at" AS "createdAt", + "accepted_game_tick" AS "acceptedGameTick", + "accepted_clock_revision" AS "acceptedClockRevision", + "accepted_deadline_generation" AS "acceptedDeadlineGeneration" FROM "input_event" WHERE "target" = 'ENGINE'::"InputEventTarget" AND "status" = 'PENDING'::"InputEventStatus" + AND ( + ${gameplayAllowed} + OR "event_type" = 'getStatus' + OR ( + ${suspendedTournamentBetCommand} + AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta') + AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback') + ) + OR ( + ${maintenanceSuspended} + AND "event_type" IN ( + 'inheritanceAction', + 'dropItem', + 'changePermission', + 'appoint', + 'setNationSetting', + 'setNpcPolicy', + 'shiftSchedule' + ) + ) + OR ( + ${maintenanceSuspended} + AND "event_type" = 'messageRespond' + AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$' + AND EXISTS ( + SELECT 1 + FROM "message_action" AS pending_action + WHERE pending_action."message_id" = ("input_event"."payload" ->> 'messageId')::integer + AND pending_action."status" = 'PENDING' + AND pending_action."clock_revision" = ${currentRevision} + AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null} + ) + ) + OR ( + ${world?.clockPhase === 'SUSPENDED'} + AND "event_type" = 'messageRespond' + AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$' + 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 + FROM "clock_suspension" AS active_suspension + WHERE active_suspension."status" = 'SUSPENDED' + AND active_suspension."source" = 'UNIFICATION_WAIT' + AND active_suspension."source_revision" = ${currentRevision} + ) + ) + ) ORDER BY "sequence" ASC FOR UPDATE SKIP LOCKED LIMIT ${limit} @@ -133,41 +220,93 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T if (rows.length === 0) { return []; } - await transaction.inputEvent.updateMany({ - where: { - sequence: { in: rows.map((row) => row.sequence) }, - target: 'ENGINE', - status: 'PENDING', - }, - data: { - status: 'PROCESSING', - processingAt: new Date(), - lockedBy: this.workerId, - leaseUntil: new Date(Date.now() + this.leaseDurationMs), - attempts: { increment: 1 }, - }, - }); + const appliedSuspensions = currentRevision + ? await transaction.clockSuspension.findMany({ + where: { status: 'APPLIED', targetRevision: { lte: currentRevision } }, + orderBy: { sourceRevision: 'asc' }, + select: { sourceRevision: true, targetRevision: true, shiftTicks: true }, + }) + : []; + const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => { + if (row.eventType === 'getStatus') return row.acceptedGameTick ?? claimCoordinate.gameTick; + if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) { + return claimCoordinate.gameTick; + } + if (row.acceptedClockRevision > currentRevision) return undefined; + let revision = row.acceptedClockRevision; + let tick = row.acceptedGameTick; + while (revision < currentRevision) { + const step = appliedSuspensions.find((entry) => entry.sourceRevision === revision); + if (!step || step.shiftTicks === null || step.targetRevision !== revision + 1n) return undefined; + tick += step.shiftTicks; + revision = step.targetRevision; + } + return tick; + }; + const processableRows = rows + .map((row) => ({ row, processingGameTick: convertTick(row) })) + .filter( + (entry): entry is { row: (typeof rows)[number]; processingGameTick: bigint | null } => + entry.processingGameTick !== undefined + ); + for (const { row, processingGameTick } of processableRows) { + 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[] = []; - for (const row of rows) { + for (const { row, processingGameTick } of processableRows) { const command = normalizeTurnDaemonCommand({ requestId: row.requestId, sentAt: row.createdAt.toISOString(), 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) { + const value = Number(processingGameTick); + if (!Number.isSafeInteger(value)) { + 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; @@ -175,23 +314,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 @@ -212,19 +348,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/databaseTurnDaemonLease.ts b/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts index b4eeba3e..3d0778b2 100644 --- a/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts +++ b/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts @@ -86,9 +86,9 @@ export class DatabaseTurnDaemonLease { VALUES ( ${this.profile}, ${this.ownerId}, - CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'), + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'), 1, - CURRENT_TIMESTAMP + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ) ON CONFLICT ("profile") DO UPDATE SET @@ -99,10 +99,10 @@ export class DatabaseTurnDaemonLease { THEN "turn_daemon_lease"."fencing_epoch" ELSE "turn_daemon_lease"."fencing_epoch" + 1 END, - "heartbeat_at" = CURRENT_TIMESTAMP + "heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id" - OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP + OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC' RETURNING "profile", "owner_id", "fencing_epoch" `); const row = rows[0]; @@ -141,13 +141,13 @@ export class DatabaseTurnDaemonLease { const rows = await this.db.$queryRaw(GamePrisma.sql` UPDATE "turn_daemon_lease" SET - "lease_until" = CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'), - "heartbeat_at" = CURRENT_TIMESTAMP + "lease_until" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'), + "heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE "profile" = ${token.profile} AND "owner_id" = ${token.ownerId} AND "fencing_epoch" = ${token.fencingEpoch} - AND "lease_until" > CURRENT_TIMESTAMP + AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC' RETURNING "profile", "owner_id", "fencing_epoch" `); if (rows.length === 0) { @@ -177,7 +177,7 @@ export class DatabaseTurnDaemonLease { "profile" = ${token.profile} AND "owner_id" = ${token.ownerId} AND "fencing_epoch" = ${token.fencingEpoch} - AND "lease_until" > CURRENT_TIMESTAMP + AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC' FOR UPDATE `); if (rows.length === 0) { @@ -196,7 +196,8 @@ export class DatabaseTurnDaemonLease { } await this.db.$executeRaw(GamePrisma.sql` UPDATE "turn_daemon_lease" - SET "lease_until" = CURRENT_TIMESTAMP, "heartbeat_at" = CURRENT_TIMESTAMP + SET "lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + "heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' WHERE "profile" = ${token.profile} AND "owner_id" = ${token.ownerId} 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 c3ae3878..bc8f2a18 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -172,7 +172,20 @@ export class TurnDaemonLifecycle { const nowMs = this.clock.nowMs(); const wallNow = new Date(nowMs); - const gameClock = await this.stateStore.loadGameClock?.(wallNow); + let gameClock = await this.stateStore.loadGameClock?.(wallNow); + if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) { + await this.stateStore.promotePreopenAtOpening(wallNow); + gameClock = await this.stateStore.loadGameClock?.(wallNow); + } + if ( + gameClock?.phase && + gameClock.phase !== 'RUNNING' && + gameClock.phase !== 'MANUAL' + ) { + this.status.nextTurnTime = undefined; + await this.clock.sleepMs(500); + continue; + } if (gameClock?.mode === 'manual') { // Ref observes all generals due before one monthly boundary in // a single snapshot. Manual mode advances directly to that @@ -209,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); } @@ -255,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 81ef8936..ca27dbb0 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -7,7 +7,7 @@ import type { TurnRunResult, } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; -import type { GameClockMode } from '@sammo-ts/common'; +import type { GameClockMode, GameClockPhase } from '@sammo-ts/common'; export type { RunReason, @@ -29,6 +29,12 @@ export interface TurnDaemonCommandHandler { export interface TurnDaemonCommandExecutionContext { db?: GamePrisma.TransactionClient; + clockOperationAuthority?: { + kind: 'DAEMON'; + profileName: string; + ownerId: string; + fencingEpoch: bigint; + }; } export interface TurnDaemonCommandResponder { @@ -60,7 +66,14 @@ export interface TurnStateStore { loadCheckpoint(): Promise; saveCheckpoint(checkpoint?: TurnCheckpoint): Promise; shouldHaltScheduledRuns?(): Promise; - loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>; + loadGameClock?(wallNow?: Date): Promise<{ + mode: GameClockMode; + now: Date; + phase?: GameClockPhase; + revision?: number; + deadlineGeneration?: number; + }>; + promotePreopenAtOpening?(wallNow: Date): Promise; shouldRebaseRealtimeBacklog?(wallNow: Date): Promise; rebaseRealtimeBacklog?(wallNow: Date): Promise; advanceGameClockTo?(target: Date, wallNow: Date): Promise; @@ -69,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/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 67a34404..01e101b5 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -6,7 +6,7 @@ import { type InputJsonValue, type TurnEngineEventCreateManyInput, } from '@sammo-ts/infra'; -import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common'; +import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common'; import { buildScenarioBootstrap, resolveScenarioGeneralDeathMonth, @@ -94,6 +94,17 @@ export const calculateInitialTurnTick = ( return clock.addTicks(baseTick, offsetTicks); }; +export const resolveInitialClockPhase = ( + mode: GameClockMode, + seededAtWall: Date, + scheduledOpenAtWall: Date +): GameClockPhase => { + if (mode === 'manual') { + return 'MANUAL'; + } + return scheduledOpenAtWall.getTime() > seededAtWall.getTime() ? 'PREOPEN' : 'RUNNING'; +}; + const formatDateTime = (date: Date): string => { const pad = (value: number): string => String(value).padStart(2, '0'); return [ @@ -234,14 +245,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom // A realtime season prepared before its formal opening must not consume // wall time while users are only allowed to edit reserved commands. const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now; + const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor); const initialClock = new GameClock({ baseTime: startState.startTime, tick: 0, mode: gameClockMode, wallAnchor: initialClockWallAnchor, turnSeconds: tickSeconds, + phase: initialClockPhase, + revision: 1, }); - const initialClockTick = initialClock.dateToTick(now); + // The formal opening wall instant is always logical tick zero. PREOPEN may + // project signed negative observed ticks, but executable seed schedules are + // derived from this zero coordinate rather than from the seed wall time. + const initialClockTick = 0; const { seed, warnings } = buildScenarioBootstrap({ scenario: scenarioDefinition, @@ -327,6 +344,10 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom } worldMeta.hiddenSeed = hiddenSeed; + worldMeta.seededAtWall = now.toISOString(); + worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString(); + worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString(); + worldMeta.calendarStart = startState.startTime.toISOString(); if (install?.preopenAt) { worldMeta.preopenAt = formatDateTime(install.preopenAt); @@ -420,6 +441,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom clockMode: gameClockMode, clockWallAnchor: initialClock.wallAnchor, lastTurnTick: BigInt(initialClockTick), + clockPhase: initialClockPhase, + clockRevision: 1n, + deadlineGeneration: 1n, config: asJson({ ...scenarioConfig, ...worldConfig }), meta: asJson(worldMeta), }, @@ -590,13 +614,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom weaponCode: general.weapon ?? 'None', bookCode: general.book ?? 'None', itemCode: general.item ?? 'None', - turnTime: new Date( - now.getTime() + - Math.floor( - (typeof general.meta.initialTurnOffsetMicros === 'number' - ? general.meta.initialTurnOffsetMicros - : 0) / 1_000 - ) + turnTime: initialClock.tickToDate( + calculateInitialTurnTick( + initialClock, + initialClockTick, + typeof general.meta.initialTurnOffsetMicros === 'number' + ? general.meta.initialTurnOffsetMicros + : 0 + ) ), turnTick: BigInt( calculateInitialTurnTick( diff --git a/app/game-engine/src/turn/actionableMessageResponse.ts b/app/game-engine/src/turn/actionableMessageResponse.ts index d77ec066..a8691732 100644 --- a/app/game-engine/src/turn/actionableMessageResponse.ts +++ b/app/game-engine/src/turn/actionableMessageResponse.ts @@ -8,6 +8,7 @@ import type { ImmediateGeneralActionExecutor } from './reservedTurnHandler.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import type { TurnEvent } from './types.js'; +import { reconcileClockSuspensionInTransaction, type ClockReconciliationResult } from './clockReconciliation.js'; type ActionableMessageType = 'scout' | 'raiseInvader'; @@ -17,6 +18,10 @@ interface MessageRow { type: string; time: Date; validUntil: Date; + actionType: string; + actionStatus: string; + createdGameTick: bigint; + expiresGameTick: bigint | null; message: unknown; } @@ -93,15 +98,21 @@ const invalidateMessageIds = async ( db: GamePrisma.TransactionClient, world: InMemoryTurnWorld, ids: number[], - now: Date + now: Date, + authoritativeGameTick?: bigint ): Promise => { const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))]; if (uniqueIds.length === 0) return; + const resolvedGameTick = authoritativeGameTick ?? 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, }, }); }; @@ -112,40 +123,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; }; @@ -164,7 +188,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: '유효하지 않은 등용장입니다.' }; } @@ -185,18 +209,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({ @@ -251,6 +274,18 @@ const respondToRaiseInvader = async (options: { payload: MessagePayload; now: Date; loadArchivedNationMaxId?: (serverId: string) => Promise; + clockOperationAuthority?: { + kind: 'DAEMON'; + profileName: string; + ownerId: string; + fencingEpoch: bigint; + }; + reconcileUnificationWait?: (input: { + db: GamePrisma.TransactionClient; + suspensionId: string; + profileName: string; + authority: NonNullable[0]['authority']>; + }) => Promise; }): Promise => { const { db, world, reservedTurns, actorId, response, row, payload, now } = options; if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) { @@ -272,6 +307,22 @@ const respondToRaiseInvader = async (options: { if (!reservedTurns) { throw new Error('RaiseInvader message response requires the reserved-turn store.'); } + const suspensionId = + typeof state.meta.unificationClockSuspensionId === 'string' ? state.meta.unificationClockSuspensionId : null; + if (!suspensionId || !options.clockOperationAuthority) { + throw new Error('RaiseInvader requires a daemon-authorized UNIFICATION_WAIT suspension.'); + } + const reconcile = + options.reconcileUnificationWait ?? + ((input) => reconcileClockSuspensionInTransaction({ ...input, allowUnificationWait: true })); + const alignment = await reconcile({ + db, + suspensionId, + profileName: options.clockOperationAuthority.profileName, + authority: options.clockOperationAuthority, + }); + world.applyClockReconciliation(alignment); + const alignedState = world.getState(); const args = asRecord(payload.option).args; if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) { return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' }; @@ -281,22 +332,44 @@ const respondToRaiseInvader = async (options: { reservedTurns, env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()), loadArchivedNationMaxId: options.loadArchivedNationMaxId, + clockWallNow: alignment.resumeWallAt, }); const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} }; await handler( args, { - year: state.currentYear, - month: state.currentMonth, - startyear: asNumber(state.meta.startYear, state.currentYear), + year: alignedState.currentYear, + month: alignedState.currentMonth, + startyear: asNumber(alignedState.meta.startYear, alignedState.currentYear), currentEventID: 0, - // Ref uses the frozen game_env.turntime while unification is paused. - // `now` is the realtime game projection and can be hours ahead after - // a long response wait, delaying every newly summoned invader turn. - turnTime: state.lastTurnTime, + // The exact reconciliation snapshot is the authority even when the + // turn rate stays unchanged. Using the pre-reconciliation cursor here + // would create immediately overdue invader turns after a long wait. + turnTime: alignedState.lastTurnTime, }, event ); + const resolvedGameTick = BigInt(alignment.alignedTick); + if (resolvedGameTick < row.createdGameTick) { + throw new Error( + `RaiseInvader resolved tick ${resolvedGameTick} precedes prompt tick ${row.createdGameTick}; clock authority is inconsistent.` + ); + } + const promptRows = await db.$queryRaw>(GamePrisma.sql` + SELECT message_id AS id + FROM message_action + WHERE action_type = 'raiseInvader' + AND status = 'PENDING' + AND created_game_tick = ${row.createdGameTick} + FOR UPDATE + `); + await invalidateMessageIds( + db, + world, + promptRows.map(({ id }) => id), + world.gameTickToDate(alignment.alignedTick), + resolvedGameTick + ); return { ok: true, action: 'raiseInvader', reason: 'success' }; }; @@ -311,14 +384,27 @@ export const respondToActionableMessage = async (options: { messageId: number; response: boolean; loadArchivedNationMaxId?: (serverId: string) => Promise; + clockOperationAuthority?: { + kind: 'DAEMON'; + profileName: string; + ownerId: string; + fencingEpoch: bigint; + }; + reconcileUnificationWait?: (input: { + db: GamePrisma.TransactionClient; + suspensionId: string; + profileName: string; + 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/clockProjectionOutbox.ts b/app/game-engine/src/turn/clockProjectionOutbox.ts new file mode 100644 index 00000000..705631ed --- /dev/null +++ b/app/game-engine/src/turn/clockProjectionOutbox.ts @@ -0,0 +1,341 @@ +import { createHash } from 'node:crypto'; + +import { GameClock, parseGameClockPhase } from '@sammo-ts/common'; +import { + CLOCK_OPERATION_PERSISTENCE_LOCK, + GamePrisma, + acquireGameSchemaAdvisoryXactLock, + type GamePrismaClient, +} from '@sammo-ts/infra'; + +export interface ClockProjectionRedis { + get(key: string): Promise; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; +} + +interface ClaimedOutboxRow { + id: bigint; +} + +interface DbWallRow { + wallNow: Date; +} + +interface ProjectionPayload { + version: 1; + profileName: string; + suspensionId: string; + sourceRevision: number; + targetRevision: number; + deadlineGeneration: number; + shiftTicks: number; + projectionDeltaMilliseconds: number; + clockBaseTime: string; + ticksPerSecond: number; +} + +interface TournamentProjectionState { + stage?: number; + nextAt?: string; + nextTick?: number; + bettingCloseAt?: string; + bettingCloseTick?: number; + clockRevision?: number; + deadlineGeneration?: number; + [key: string]: unknown; +} + +const APPLY_CLOCK_PROJECTION_SCRIPT = ` +local active = redis.call('GET', KEYS[1]) +if active == ARGV[2] then + if redis.call('GET', KEYS[5]) == ARGV[4] and redis.call('GET', KEYS[2]) == ARGV[3] then + return 2 + end + return -3 +end +if active and active ~= ARGV[1] then + return -1 +end +if ARGV[5] ~= '__NONE__' and redis.call('GET', KEYS[4]) ~= ARGV[5] then + return -2 +end +redis.call('DEL', KEYS[3]) +local count = tonumber(ARGV[7]) +local offset = 8 +for index = 1, count do + redis.call('ZADD', KEYS[3], ARGV[offset], ARGV[offset + 1]) + offset = offset + 2 +end +if ARGV[5] ~= '__NONE__' then + redis.call('SET', KEYS[4], ARGV[6]) +end +redis.call('SET', KEYS[1], ARGV[2]) +redis.call('SET', KEYS[2], ARGV[3]) +redis.call('SET', KEYS[5], ARGV[4]) +redis.call('SET', KEYS[6], 'RUNNING') +return 1 +`; + +const safeInteger = (value: unknown, label: string): number => { + const result = typeof value === 'bigint' ? Number(value) : value; + if (typeof result !== 'number' || !Number.isSafeInteger(result)) { + throw new Error(`${label} must be a safe integer.`); + } + return result; +}; + +const canonicalize = (value: unknown): unknown => { + if (typeof value === 'bigint') return value.toString(); + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalize(item)]) + ); + } + return value; +}; + +const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value)); + +const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex'); + +const readDbWall = async (db: GamePrisma.TransactionClient): Promise => { + const rows = await db.$queryRaw(GamePrisma.sql` + SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow" + `); + if (!rows[0]?.wallNow) throw new Error('Failed to read PostgreSQL wall time for the projection outbox.'); + return rows[0].wallNow; +}; + +const parsePayload = (value: GamePrisma.JsonValue): ProjectionPayload => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Clock projection outbox payload must be an object.'); + } + const payload = value as Record; + if (payload.version !== 1 || typeof payload.profileName !== 'string' || typeof payload.suspensionId !== 'string') { + throw new Error('Clock projection outbox payload identity is invalid.'); + } + if (typeof payload.clockBaseTime !== 'string') { + throw new Error('Clock projection outbox is missing its projection base.'); + } + return { + version: 1, + profileName: payload.profileName, + suspensionId: payload.suspensionId, + sourceRevision: safeInteger(payload.sourceRevision, 'sourceRevision'), + targetRevision: safeInteger(payload.targetRevision, 'targetRevision'), + deadlineGeneration: safeInteger(payload.deadlineGeneration, 'deadlineGeneration'), + shiftTicks: safeInteger(payload.shiftTicks, 'shiftTicks'), + projectionDeltaMilliseconds: safeInteger(payload.projectionDeltaMilliseconds, 'projectionDeltaMilliseconds'), + clockBaseTime: payload.clockBaseTime, + ticksPerSecond: safeInteger(payload.ticksPerSecond, 'ticksPerSecond'), + }; +}; + +const claimNext = async (db: GamePrismaClient, workerId: string) => + db.$transaction(async (transaction) => { + const rows = await transaction.$queryRaw(GamePrisma.sql` + SELECT id + FROM clock_projection_outbox + WHERE available_at <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + AND ( + status IN ('PENDING', 'FAILED') + OR (status = 'APPLYING' AND locked_at < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds') + ) + ORDER BY id + LIMIT 1 + FOR UPDATE SKIP LOCKED + `); + const id = rows[0]?.id; + if (id === undefined) return null; + const lockedAt = await readDbWall(transaction); + return transaction.clockProjectionOutbox.update({ + where: { id }, + data: { + status: 'APPLYING', + attempts: { increment: 1 }, + lockedAt, + lockedBy: workerId, + lastError: null, + }, + }); + }); + +const projectTournamentState = ( + raw: string | null, + payload: ProjectionPayload, + clock: GameClock +): { expected: string; next: string } | null => { + if (!raw) return null; + const parsed = JSON.parse(raw) as TournamentProjectionState; + const active = typeof parsed.stage === 'number' && parsed.stage > 0; + if (active && parsed.nextAt && !Number.isSafeInteger(parsed.nextTick)) { + throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.'); + } + if (active && parsed.bettingCloseAt && !Number.isSafeInteger(parsed.bettingCloseTick)) { + throw new Error('Active tournament bettingCloseAt lacks the authoritative bettingCloseTick dual-write.'); + } + const next: TournamentProjectionState = { + ...parsed, + clockRevision: payload.targetRevision, + deadlineGeneration: payload.deadlineGeneration, + }; + if (Number.isSafeInteger(parsed.nextTick)) { + next.nextTick = parsed.nextTick! + payload.shiftTicks; + next.nextAt = clock.tickToDate(next.nextTick).toISOString(); + } + if (Number.isSafeInteger(parsed.bettingCloseTick)) { + next.bettingCloseTick = parsed.bettingCloseTick! + payload.shiftTicks; + next.bettingCloseAt = clock.tickToDate(next.bettingCloseTick).toISOString(); + } + return { expected: raw, next: JSON.stringify(next) }; +}; + +const recordFailure = async (db: GamePrismaClient, outboxId: bigint, error: unknown): Promise => { + const message = error instanceof Error ? error.message : String(error); + await db.$executeRaw(GamePrisma.sql` + UPDATE clock_projection_outbox + SET status = 'FAILED', + locked_at = NULL, + locked_by = NULL, + last_error = ${message.slice(0, 4_000)}, + available_at = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '1 second' + WHERE id = ${outboxId} AND status = 'APPLYING' + `); +}; + +export const applyNextClockProjection = async (options: { + db: GamePrismaClient; + redis: ClockProjectionRedis; + workerId: string; +}): Promise<'IDLE' | 'APPLIED' | 'RECOVERED'> => { + if (!options.workerId.trim()) throw new Error('Clock projection worker ID is required.'); + const outbox = await claimNext(options.db, options.workerId); + if (!outbox) return 'IDLE'; + try { + const payload = parsePayload(outbox.payload); + if (checksum(outbox.payload) !== outbox.checksum) { + throw new Error('Clock projection outbox checksum verification failed.'); + } + if (outbox.targetRevision !== BigInt(payload.targetRevision)) { + throw new Error('Clock projection payload revision differs from its outbox row.'); + } + const world = await options.db.worldState.findUniqueOrThrow({ where: { id: outbox.worldStateId } }); + if ( + parseGameClockPhase(world.clockPhase) !== 'RECONCILING' || + world.clockRevision !== outbox.targetRevision || + world.deadlineGeneration !== BigInt(payload.deadlineGeneration) || + !world.clockBaseTime + ) { + throw new Error('Clock projection DB phase/revision/generation fence failed.'); + } + const clock = new GameClock({ + baseTime: new Date(payload.clockBaseTime), + tick: safeInteger(world.clockTick, 'world clock tick'), + mode: world.clockMode === 'manual' ? 'manual' : 'realtime', + wallAnchor: world.clockWallAnchor ?? new Date(), + turnSeconds: world.tickSeconds, + phase: 'RECONCILING', + revision: payload.targetRevision, + }); + if (clock.ticksPerSecond !== payload.ticksPerSecond) { + throw new Error('Clock projection rate differs from the durable outbox payload.'); + } + const auctions = await options.db.auction.findMany({ + where: { status: { in: ['OPEN', 'FINALIZING'] } }, + orderBy: { id: 'asc' }, + select: { id: true, closeTick: true }, + }); + const timers = auctions.map((auction) => { + if (auction.closeTick === null) { + throw new Error(`Active auction ${auction.id} lacks closeTick during projection rebuild.`); + } + return { score: safeInteger(auction.closeTick, `auction ${auction.id} closeTick`), id: String(auction.id) }; + }); + const prefix = `sammo:${payload.profileName}`; + const tournamentKey = `${prefix}:tournament:state`; + const tournament = projectTournamentState(await options.redis.get(tournamentKey), payload, clock); + const result = await options.redis.eval(APPLY_CLOCK_PROJECTION_SCRIPT, { + keys: [ + `${prefix}:clock:active-revision`, + `${prefix}:clock:deadline-generation`, + `${prefix}:auction:timer`, + tournamentKey, + `${prefix}:clock:projection-checksum`, + `${prefix}:clock:phase`, + ], + arguments: [ + String(payload.sourceRevision), + String(payload.targetRevision), + String(payload.deadlineGeneration), + outbox.checksum, + tournament?.expected ?? '__NONE__', + tournament?.next ?? '__NONE__', + String(timers.length), + ...timers.flatMap(({ score, id }) => [String(score), id]), + ], + }); + const applied = Number(result); + if (applied === -1) throw new Error('Redis active clock revision does not match the outbox source revision.'); + if (applied === -2) throw new Error('Redis tournament state changed while rebuilding its projection.'); + if (applied === -3) throw new Error('Redis target revision exists without the expected projection checksum.'); + if (applied !== 1 && applied !== 2) + throw new Error(`Unexpected Redis clock projection result: ${String(result)}`); + + await options.db.$transaction(async (transaction) => { + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + await transaction.$queryRaw(GamePrisma.sql` + SELECT id FROM world_state WHERE id = ${outbox.worldStateId} FOR UPDATE + `); + const finalized = await transaction.worldState.updateMany({ + where: { + id: outbox.worldStateId, + clockPhase: 'RECONCILING', + clockRevision: outbox.targetRevision, + deadlineGeneration: BigInt(payload.deadlineGeneration), + }, + data: { clockPhase: 'RUNNING' }, + }); + if (finalized.count !== 1) { + throw new Error('Clock projection final RUNNING transition fence failed.'); + } + const appliedAt = await readDbWall(transaction); + await transaction.clockProjectionOutbox.update({ + where: { id: outbox.id }, + data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null }, + }); + if (outbox.suspensionId) { + await transaction.clockSuspension.update({ + where: { id: outbox.suspensionId }, + data: { status: 'APPLIED' }, + }); + } + }); + return applied === 2 ? 'RECOVERED' : 'APPLIED'; + } catch (error) { + await recordFailure(options.db, outbox.id, error); + throw error; + } +}; + +export const loadClockReconciliationReadiness = async (db: GamePrismaClient) => { + const world = await db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { clockPhase: true, clockRevision: true, deadlineGeneration: true }, + }); + const incompleteOutboxCount = await db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } }); + if (!world) { + return { ready: false, phase: null, revision: null, deadlineGeneration: null, incompleteOutboxCount }; + } + const phase = parseGameClockPhase(world.clockPhase); + return { + ready: phase !== 'RECONCILING' && incompleteOutboxCount === 0, + gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL', + phase, + revision: safeInteger(world.clockRevision, 'clock revision'), + deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'), + incompleteOutboxCount, + }; +}; diff --git a/app/game-engine/src/turn/clockReconciliation.ts b/app/game-engine/src/turn/clockReconciliation.ts new file mode 100644 index 00000000..2d3248c2 --- /dev/null +++ b/app/game-engine/src/turn/clockReconciliation.ts @@ -0,0 +1,1044 @@ +import { createHash } from 'node:crypto'; + +import { + GAME_TICKS_PER_TURN, + MAX_SAFE_GAME_TICK, + GameClock, + buildClockAlignmentPlan, + parseClockAlignmentPolicy, + parseGameClockPhase, + type ClockAlignmentPolicy, +} from '@sammo-ts/common'; +import { + CLOCK_OPERATION_PERSISTENCE_LOCK, + GENERAL_ACCESS_PERSISTENCE_LOCK, + GamePrisma, + acquireGameSchemaAdvisoryXactLock, + type GamePrismaClient, +} from '@sammo-ts/infra'; + +export type ClockSuspensionSource = 'MAINTENANCE' | 'OPEN_DELAY' | 'UNIFICATION_WAIT' | 'RECOVERY'; + +export type ClockOperationAuthority = + | { kind: 'DAEMON'; profileName: string; ownerId: string; fencingEpoch: bigint } + | { kind: 'OFFLINE'; profileName: string; reason: string }; + +export interface ClockSuspensionResult { + suspensionId: string; + phase: 'SUSPENDED'; + sourceRevision: number; + targetRevision: number; + cutTick: number; + cutWallAt: Date; +} + +export interface ClockReconciliationResult { + suspensionId: string; + phase: 'RECONCILING'; + sourceRevision: number; + targetRevision: number; + deadlineGeneration: number; + gapTicks: number; + catchUpTicks: number; + shiftTicks: number; + alignedTick: number; + resumeWallAt: Date; +} + +interface DbWallRow { + wallNow: Date; +} + +interface LeaseFenceRow { + ownerId: string; + fencingEpoch: bigint; + valid: boolean; +} + +interface IdRow { + id: number; +} + +interface TextIdRow { + id: string; +} + +interface ParticipantSnapshot { + key: string; + policy: 'SHIFT' | 'KEEP' | 'REBUILD'; + checksum: string; + count: number; +} + +const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; + +const safeNumber = (value: bigint, label: string): number => { + const result = Number(value); + if (!Number.isSafeInteger(result)) { + throw new Error(`${label} is outside the JavaScript safe integer range: ${value}`); + } + return result; +}; + +const canonicalize = (value: unknown): unknown => { + if (typeof value === 'bigint') return value.toString(); + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalize(item)]) + ); + } + return value; +}; + +const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value)); + +const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex'); + +const aggregateChecksum = (participants: readonly ParticipantSnapshot[]): string => + checksum(participants.map(({ key, policy, checksum: value, count }) => ({ key, policy, checksum: value, count }))); + +const readDbWall = async (db: GamePrisma.TransactionClient): Promise => { + const rows = await db.$queryRaw(GamePrisma.sql` + SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow" + `); + const wallNow = rows[0]?.wallNow; + if (!wallNow || Number.isNaN(wallNow.getTime())) { + throw new Error('Failed to read the PostgreSQL wall clock.'); + } + return wallNow; +}; + +export const readClockDatabaseWall = readDbWall; + +const isRetryableSerializableClockError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') return false; + const value = error as { code?: unknown; message?: unknown; meta?: unknown }; + const meta = + value.meta && typeof value.meta === 'object' + ? (value.meta as { code?: unknown; message?: unknown }) + : undefined; + const code = typeof value.code === 'string' ? value.code : ''; + const databaseCode = typeof meta?.code === 'string' ? meta.code : ''; + const message = [value.message, meta?.message] + .filter((entry): entry is string => typeof entry === 'string') + .join(' '); + return ( + code === 'P2034' || + code === '40001' || + databaseCode === '40001' || + message.includes('40001') || + message.includes('could not serialize access') + ); +}; + +const runSerializableClockOperation = async (operation: () => Promise): Promise => { + const maxAttempts = 3; + for (let attempt = 1; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (attempt >= maxAttempts || !isRetryableSerializableClockError(error)) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, attempt * 10)); + } + } +}; + +const verifyAuthority = async (db: GamePrisma.TransactionClient, authority: ClockOperationAuthority): Promise => { + const rows = await db.$queryRaw(GamePrisma.sql` + SELECT owner_id AS "ownerId", + fencing_epoch AS "fencingEpoch", + lease_until > (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS valid + FROM turn_daemon_lease + WHERE profile = ${authority.profileName} + FOR UPDATE + `); + const lease = rows[0]; + if (authority.kind === 'OFFLINE') { + if (!authority.reason.trim()) { + throw new Error('Offline clock operations require an audit reason.'); + } + if (lease?.valid) { + throw new Error(`Clock operation requires the ${authority.profileName} daemon lease to be offline.`); + } + return; + } + if (!lease?.valid || lease.ownerId !== authority.ownerId || lease.fencingEpoch !== authority.fencingEpoch) { + throw new Error(`Stale turn-daemon fencing authority for profile ${authority.profileName}.`); + } +}; + +const lockWorld = async (db: GamePrisma.TransactionClient): Promise => { + const rows = await db.$queryRaw(GamePrisma.sql` + SELECT id FROM world_state ORDER BY id LIMIT 2 FOR UPDATE + `); + if (rows.length !== 1) { + throw new Error(`Clock reconciliation requires exactly one world_state row; found ${rows.length}.`); + } + return rows[0]!.id; +}; + +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 + WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status) + ORDER BY id FOR UPDATE + `); + await db.$queryRaw(GamePrisma.sql` + 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 + `); + await db.$queryRaw(GamePrisma.sql` + SELECT id FROM select_pool WHERE general_id IS NULL ORDER BY id FOR UPDATE + `); + await db.$queryRaw(GamePrisma.sql` + SELECT owner_user_id AS id FROM select_npc_token ORDER BY owner_user_id FOR UPDATE + `); +}; + +const readParticipantSnapshots = async ( + db: GamePrisma.TransactionClient, + worldStateId: number, + cutTick: bigint +): Promise => { + const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] = + await Promise.all([ + db.worldState.findUniqueOrThrow({ + where: { id: worldStateId }, + select: { + clockTick: true, + clockRevision: true, + deadlineGeneration: true, + lastTurnTick: true, + meta: true, + }, + }), + db.general.findMany({ + orderBy: { id: 'asc' }, + 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.auctionBid.findMany({ + where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } }, + orderBy: { id: 'asc' }, + 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 }, + orderBy: { id: 'asc' }, + select: { id: true, startTick: true, endTick: true }, + }), + db.selectPoolEntry.findMany({ + where: { generalId: null }, + orderBy: { id: 'asc' }, + select: { id: true, reservedUntilTick: true }, + }), + db.npcSelectionToken.findMany({ + orderBy: { ownerUserId: 'asc' }, + select: { ownerUserId: true, validUntilTick: true, pickMoreFromTick: true }, + }), + db.inputEvent.findMany({ + where: { status: { in: ['PENDING', 'PROCESSING'] } }, + orderBy: { sequence: 'asc' }, + select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true }, + }), + ]); + const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({ + key, + policy, + checksum: checksum(rows), + count: rows.length, + }); + const meta = world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta) ? world.meta : {}; + return [ + snapshot('world-clock', 'REBUILD', [ + { + clockTick: world.clockTick, + clockRevision: world.clockRevision, + deadlineGeneration: world.deadlineGeneration, + }, + ]), + snapshot('turn-cursor', 'SHIFT', [{ lastTurnTick: world.lastTurnTick }]), + snapshot( + 'general-next-turn', + 'SHIFT', + generals.map(({ id, turnTick }) => ({ id, turnTick })) + ), + snapshot( + 'general-recent-war-occurrence', + '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', + auctions.map(({ id, openTick }) => ({ id, openTick })) + ), + snapshot( + 'auction-deadline', + '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-action-occurrence', + 'KEEP', + messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick })) + ), + snapshot( + 'message-action-expiry', + 'SHIFT', + 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', + votes.map(({ id, startTick }) => ({ id, startTick })) + ), + snapshot( + 'vote-end-deadline', + 'SHIFT', + votes.map(({ id, endTick }) => ({ id, endTick })) + ), + snapshot('select-pool-reservation', 'SHIFT', pool), + snapshot('npc-selection-window', 'SHIFT', npcTokens), + snapshot('daemon-command-coordinate', 'KEEP', commands), + snapshot('movable-json-rule-anchors', 'SHIFT', [ + { + lastTurnTime: Reflect.get(meta, 'lastTurnTime'), + turntime: Reflect.get(meta, 'turntime'), + starttime: Reflect.get(meta, 'starttime'), + tnmt_time: Reflect.get(meta, 'tnmt_time'), + }, + ]), + ]; +}; + +const persistInitialParticipants = async ( + db: GamePrisma.TransactionClient, + suspensionId: string, + participants: readonly ParticipantSnapshot[] +): Promise => { + for (const participant of participants) { + await db.clockReconciliationParticipant.create({ + data: { + suspensionId, + participantKey: participant.key, + policy: participant.policy, + beforeChecksum: participant.checksum, + afterChecksum: participant.checksum, + affectedCount: 0, + }, + }); + } +}; + +/** + * Locks every registered participant before an enclosing transaction mutates + * the world into a suspended state. The caller must already hold the daemon, + * clock-operation, general-access, and world-row lock prefix. + */ +export const prepareClockSuspensionUnderHeldLocks = async (options: { + db: GamePrisma.TransactionClient; + cutTick: number; + cutWallAt?: Date; +}): Promise<{ cutWallAt: Date }> => { + if (!Number.isSafeInteger(options.cutTick)) { + throw new Error(`Clock suspension cut tick is outside the safe integer range: ${options.cutTick}.`); + } + const cutWallAt = options.cutWallAt ? new Date(options.cutWallAt.getTime()) : await readDbWall(options.db); + await lockParticipants(options.db, BigInt(options.cutTick)); + return { cutWallAt }; +}; + +/** + * Persists the ledger after all suspension-boundary gameplay writes have been + * staged in the same transaction. Lock acquisition belongs to + * prepareClockSuspensionUnderHeldLocks and must happen first. + */ +export const persistClockSuspensionLedgerUnderHeldLocks = async (options: { + db: GamePrisma.TransactionClient; + suspensionId: string; + worldStateId: number; + profileName: string; + source: ClockSuspensionSource; + cutTick: number; + cutWallAt: Date; + rateTicksPerSecond: number; + sourceRevision: number; + policy?: ClockAlignmentPolicy; + catchUpTicks?: number; +}): Promise => { + if (!options.suspensionId.trim() || options.suspensionId.length > 64) { + throw new Error('Clock suspension ID must contain 1-64 characters.'); + } + const policy = options.policy ?? 'EXACT'; + const catchUpTicks = options.catchUpTicks ?? 0; + if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) { + throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.'); + } + const existing = await options.db.clockSuspension.findUnique({ where: { id: options.suspensionId } }); + if (existing) { + if ( + existing.worldStateId !== options.worldStateId || + existing.source !== options.source || + existing.sourceRevision !== BigInt(options.sourceRevision) + ) { + throw new Error(`Clock suspension ID ${options.suspensionId} is already bound to another operation.`); + } + return; + } + const world = await options.db.worldState.findUniqueOrThrow({ where: { id: options.worldStateId } }); + if ( + parseGameClockPhase(world.clockPhase) !== 'SUSPENDED' || + world.clockRevision !== BigInt(options.sourceRevision) + ) { + throw new Error('Clock suspension ledger requires a matching durable SUSPENDED world revision.'); + } + const participants = await readParticipantSnapshots(options.db, options.worldStateId, BigInt(options.cutTick)); + await options.db.clockSuspension.create({ + data: { + id: options.suspensionId, + worldStateId: options.worldStateId, + source: options.source, + policy, + status: 'SUSPENDED', + sourceRevision: BigInt(options.sourceRevision), + targetRevision: BigInt(options.sourceRevision + 1), + cutTick: BigInt(options.cutTick), + cutWallAt: options.cutWallAt, + rateTicksPerSecond: options.rateTicksPerSecond, + catchUpTicks: BigInt(catchUpTicks), + participantChecksumBefore: aggregateChecksum(participants), + detail: asJson({ authority: 'DAEMON', profileName: options.profileName }), + }, + }); + await persistInitialParticipants(options.db, options.suspensionId, participants); +}; + +const shiftMetaDate = (value: unknown, deltaMilliseconds: number): unknown => { + if (typeof value !== 'string' || !value.trim()) return value; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return new Date(parsed.getTime() + deltaMilliseconds).toISOString(); +}; + +const shiftedMeta = (value: GamePrisma.JsonValue, deltaMilliseconds: number): GamePrisma.InputJsonValue => { + const meta = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {}; + for (const key of ['lastTurnTime', 'turntime', 'starttime', 'tnmt_time'] as const) { + if (Object.hasOwn(meta, key)) { + Reflect.set(meta, key, shiftMetaDate(Reflect.get(meta, key), deltaMilliseconds)); + } + } + return asJson(meta); +}; + +const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTicks: number): void => { + if (!Number.isSafeInteger(shiftTicks) || shiftTicks < 0) { + throw new Error(`Invalid reconciliation shift: ${shiftTicks}`); + } + // Checksums retain stringified values for audit; actual row ranges are + // checked by PostgreSQL BIGINT and the world aligned tick is checked by the + // shared GameClock plan. The sentinel expiry is deliberately never shifted. + if (participants.some((participant) => !participant.checksum)) { + throw new Error('Participant snapshot is incomplete.'); + } +}; + +const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise => { + const shift = BigInt(shiftTicks); + const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift; + 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.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({ + _max: { reservedUntilTick: true }, + where: { generalId: null, reservedUntilTick: { not: null } }, + }), + db.npcSelectionToken.aggregate({ _max: { validUntilTick: true }, where: { validUntilTick: { not: null } } }), + db.npcSelectionToken.aggregate({ + _max: { pickMoreFromTick: true }, + where: { pickMoreFromTick: { not: null } }, + }), + ]); + 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_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], + ['select_npc_token.pick_more_from_tick', npcMore._max.pickMoreFromTick], + ]; + for (const [label, value] of values) { + if (value !== null && value > maximum) { + throw new Error(`${label} would exceed the safe game tick range after reconciliation.`); + } + } +}; + +const applyParticipantShift = async ( + db: GamePrisma.TransactionClient, + worldStateId: number, + cutTick: bigint, + alignedTick: bigint, + targetRevision: bigint, + targetGeneration: bigint, + shiftTicks: bigint, + projectionDeltaMilliseconds: number, + resumeWallAt: Date +): Promise> => { + const affected = new Map(); + const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId }, select: { meta: true } }); + const cursor = await db.worldState.updateMany({ + where: { id: worldStateId, lastTurnTick: { not: null } }, + data: { lastTurnTick: { increment: shiftTicks } }, + }); + affected.set('turn-cursor', cursor.count); + affected.set( + 'general-next-turn', + await db.$executeRaw(GamePrisma.sql` + UPDATE general + SET turn_tick = turn_tick + ${shiftTicks}, + turn_time = turn_time + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' + 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` + UPDATE auction + SET close_tick = close_tick + ${shiftTicks}, + close_at = close_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' + WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status) + AND close_tick IS NOT NULL + `) + ); + affected.set( + '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` + 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( + 'vote-end-deadline', + await db.$executeRaw(GamePrisma.sql` + UPDATE vote_poll + SET end_tick = end_tick + ${shiftTicks}, + end_at = end_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' + WHERE closed_at IS NULL AND end_tick IS NOT NULL + `) + ); + affected.set( + 'select-pool-reservation', + await db.$executeRaw(GamePrisma.sql` + UPDATE select_pool + SET reserved_until_tick = reserved_until_tick + ${shiftTicks}, + reserved_until = reserved_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' + WHERE general_id IS NULL AND reserved_until_tick IS NOT NULL + `) + ); + affected.set( + 'npc-selection-window', + await db.$executeRaw(GamePrisma.sql` + UPDATE select_npc_token + SET valid_until_tick = CASE + WHEN valid_until_tick IS NULL THEN NULL ELSE valid_until_tick + ${shiftTicks} END, + valid_until = CASE + WHEN valid_until_tick IS NULL THEN valid_until + ELSE valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END, + pick_more_from_tick = CASE + WHEN pick_more_from_tick IS NULL THEN NULL ELSE pick_more_from_tick + ${shiftTicks} END, + pick_more_from = CASE + WHEN pick_more_from_tick IS NULL THEN pick_more_from + ELSE pick_more_from + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END + WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL + `) + ); + await db.worldState.update({ + where: { id: worldStateId }, + data: { + clockTick: alignedTick, + clockWallAnchor: resumeWallAt, + clockPhase: 'RECONCILING', + clockRevision: targetRevision, + deadlineGeneration: targetGeneration, + meta: shiftedMeta(world.meta, projectionDeltaMilliseconds), + }, + }); + affected.set('world-clock', 1); + affected.set('movable-json-rule-anchors', 1); + affected.set('auction-finalizing-recovery', 0); + return affected; +}; + +export const startClockSuspension = async (options: { + db: GamePrismaClient; + suspensionId: string; + source: ClockSuspensionSource; + authority: ClockOperationAuthority; + policy?: ClockAlignmentPolicy; + catchUpTicks?: number; +}): Promise => { + if (!options.suspensionId.trim() || options.suspensionId.length > 64) { + throw new Error('Clock suspension ID must contain 1-64 characters.'); + } + const policy = options.policy ?? 'EXACT'; + const catchUpTicks = options.catchUpTicks ?? 0; + if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) { + throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.'); + } + return runSerializableClockOperation(() => + options.db.$transaction( + async (db) => { + await verifyAuthority(db, options.authority); + await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK); + await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK); + const worldStateId = await lockWorld(db); + const existing = await db.clockSuspension.findUnique({ where: { id: options.suspensionId } }); + if (existing) { + if ( + existing.worldStateId !== worldStateId || + existing.source !== options.source || + existing.policy !== policy + ) { + throw new Error( + `Clock suspension ID ${options.suspensionId} is already bound to another operation.` + ); + } + if (existing.status !== 'SUSPENDED') { + throw new Error( + `Clock suspension ${options.suspensionId} already advanced to ${existing.status}.` + ); + } + return { + suspensionId: existing.id, + phase: 'SUSPENDED' as const, + sourceRevision: safeNumber(existing.sourceRevision, 'source revision'), + targetRevision: safeNumber(existing.targetRevision, 'target revision'), + cutTick: safeNumber(existing.cutTick, 'cut tick'), + cutWallAt: existing.cutWallAt, + }; + } + const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }); + const phase = parseGameClockPhase(world.clockPhase); + if (phase !== 'RUNNING') { + throw new Error(`Clock suspension can start only from RUNNING; current phase is ${phase}.`); + } + if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) { + throw new Error('Clock suspension requires a fully initialized logical game clock.'); + } + const cutWallAt = await readDbWall(db); + const storedTick = safeNumber(world.clockTick, 'world clock tick'); + const sourceRevision = safeNumber(world.clockRevision, 'world clock revision'); + const clock = new GameClock({ + baseTime: world.clockBaseTime, + tick: storedTick, + mode: world.clockMode === 'manual' ? 'manual' : 'realtime', + wallAnchor: world.clockWallAnchor, + turnSeconds: world.tickSeconds, + phase, + revision: sourceRevision, + }); + const cutTick = clock.nowTick(cutWallAt); + await lockParticipants(db, BigInt(cutTick)); + await db.worldState.update({ + where: { id: worldStateId }, + data: { clockPhase: 'SUSPENDED', clockTick: BigInt(cutTick), clockWallAnchor: cutWallAt }, + }); + const participants = await readParticipantSnapshots(db, worldStateId, BigInt(cutTick)); + await db.clockSuspension.create({ + data: { + id: options.suspensionId, + worldStateId, + source: options.source, + policy, + status: 'SUSPENDED', + sourceRevision: BigInt(sourceRevision), + targetRevision: BigInt(sourceRevision + 1), + cutTick: BigInt(cutTick), + cutWallAt, + rateTicksPerSecond: GAME_TICKS_PER_TURN / world.tickSeconds, + catchUpTicks: BigInt(catchUpTicks), + participantChecksumBefore: aggregateChecksum(participants), + detail: asJson({ + authority: options.authority.kind, + profileName: options.authority.profileName, + }), + }, + }); + await persistInitialParticipants(db, options.suspensionId, participants); + return { + suspensionId: options.suspensionId, + phase: 'SUSPENDED', + sourceRevision, + targetRevision: sourceRevision + 1, + cutTick, + cutWallAt, + }; + }, + { isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 } + ) + ); +}; + +/** + * Continues a suspension inside a transaction whose caller already verified + * daemon authority and acquired the clock/general-access lock prefix. + */ +export const reconcileClockSuspensionInTransaction = async (options: { + db: GamePrisma.TransactionClient; + suspensionId: string; + profileName: string; + allowUnificationWait?: boolean; + authority?: ClockOperationAuthority; + /** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */ + testResumeWallAt?: Date; +}): Promise => { + const db = options.db; + const worldStateId = await lockWorld(db); + const suspension = await db.clockSuspension.findUniqueOrThrow({ where: { id: options.suspensionId } }); + if (suspension.worldStateId !== worldStateId) { + throw new Error('Clock suspension belongs to another world state.'); + } + if (suspension.status === 'RECONCILING' || suspension.status === 'APPLIED') { + if ( + suspension.gapTicks === null || + suspension.shiftTicks === null || + suspension.alignedTick === null || + !suspension.resumeWallAt + ) { + throw new Error('Persisted clock reconciliation result is incomplete.'); + } + const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }); + return { + suspensionId: suspension.id, + phase: 'RECONCILING' as const, + sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'), + targetRevision: safeNumber(suspension.targetRevision, 'target revision'), + deadlineGeneration: safeNumber(world.deadlineGeneration, 'deadline generation'), + gapTicks: safeNumber(suspension.gapTicks, 'gap ticks'), + catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'), + shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'), + alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'), + resumeWallAt: suspension.resumeWallAt, + }; + } + if (suspension.status !== 'SUSPENDED') { + throw new Error(`Clock suspension cannot reconcile from status ${suspension.status}.`); + } + const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }); + const phase = parseGameClockPhase(world.clockPhase); + if (phase !== 'SUSPENDED' || world.clockRevision !== suspension.sourceRevision) { + throw new Error('Clock reconciliation phase or source revision fence failed.'); + } + const worldMeta = + world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta) + ? (world.meta as Record) + : {}; + const united = Number(worldMeta.isunited ?? worldMeta.isUnited ?? 0); + if (suspension.source === 'UNIFICATION_WAIT' || united >= 2) { + if (!options.allowUnificationWait || options.authority?.kind !== 'DAEMON') { + throw new Error('Unification wait requires the daemon-authorized atomic alignment-and-invader workflow.'); + } + await verifyAuthority(db, options.authority); + } + const cutTick = safeNumber(suspension.cutTick, 'cut tick'); + await lockParticipants(db, suspension.cutTick); + if (options.testResumeWallAt && process.env.NODE_ENV !== 'test') { + throw new Error('A clock reconciliation wall override is allowed only in tests.'); + } + const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db); + const plan = buildClockAlignmentPlan({ + policy: parseClockAlignmentPolicy(suspension.policy), + sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'), + cutTick, + cutWall: suspension.cutWallAt, + resumeWall: resumeWallAt, + ticksPerSecond: suspension.rateTicksPerSecond, + catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'), + }); + const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick); + assertShiftFits(before, plan.shiftTicks); + await assertScheduleRanges(db, plan.shiftTicks); + const projectionDeltaMilliseconds = Math.trunc((plan.shiftTicks * 1_000) / suspension.rateTicksPerSecond); + if (!Number.isSafeInteger(projectionDeltaMilliseconds)) { + throw new Error('Clock reconciliation projection delta is outside the safe integer range.'); + } + const targetGeneration = world.deadlineGeneration + 1n; + const affected = await applyParticipantShift( + db, + worldStateId, + suspension.cutTick, + BigInt(plan.alignedTick), + BigInt(plan.targetRevision), + targetGeneration, + BigInt(plan.shiftTicks), + projectionDeltaMilliseconds, + resumeWallAt + ); + const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick); + const afterByKey = new Map(after.map((participant) => [participant.key, participant])); + for (const participant of before) { + const next = afterByKey.get(participant.key); + if (!next) throw new Error(`Missing post-reconciliation participant: ${participant.key}`); + if (participant.policy === 'KEEP' && participant.checksum !== next.checksum) { + throw new Error(`KEEP participant changed during reconciliation: ${participant.key}`); + } + await db.clockReconciliationParticipant.upsert({ + where: { + suspensionId_participantKey: { + suspensionId: suspension.id, + participantKey: participant.key, + }, + }, + create: { + suspensionId: suspension.id, + participantKey: participant.key, + policy: participant.policy, + beforeChecksum: participant.checksum, + afterChecksum: next.checksum, + affectedCount: affected.get(participant.key) ?? 0, + }, + update: { + policy: participant.policy, + beforeChecksum: participant.checksum, + afterChecksum: next.checksum, + affectedCount: affected.get(participant.key) ?? 0, + }, + }); + } + const outboxPayload = { + version: 1, + profileName: options.profileName, + suspensionId: suspension.id, + sourceRevision: plan.sourceRevision, + targetRevision: plan.targetRevision, + deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'), + shiftTicks: plan.shiftTicks, + projectionDeltaMilliseconds, + clockBaseTime: world.clockBaseTime!.toISOString(), + ticksPerSecond: suspension.rateTicksPerSecond, + }; + await db.clockProjectionOutbox.create({ + data: { + worldStateId, + suspensionId: suspension.id, + targetRevision: BigInt(plan.targetRevision), + status: 'PENDING', + payload: asJson(outboxPayload), + checksum: checksum(outboxPayload), + }, + }); + await db.clockSuspension.update({ + where: { id: suspension.id }, + data: { + status: 'RECONCILING', + resumeWallAt, + gapTicks: BigInt(plan.gapTicks), + shiftTicks: BigInt(plan.shiftTicks), + alignedTick: BigInt(plan.alignedTick), + participantChecksumBefore: aggregateChecksum(before), + participantChecksumAfter: aggregateChecksum(after), + }, + }); + return { + suspensionId: suspension.id, + phase: 'RECONCILING', + sourceRevision: plan.sourceRevision, + targetRevision: plan.targetRevision, + deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'), + gapTicks: plan.gapTicks, + catchUpTicks: plan.catchUpTicks, + shiftTicks: plan.shiftTicks, + alignedTick: plan.alignedTick, + resumeWallAt, + }; +}; + +/** Finalizes an atomic unification workflow after an optional rate change. */ +export const refreshClockProjectionForFinalClockUnderHeldLocks = async (options: { + db: GamePrisma.TransactionClient; + suspensionId: string; + clockBaseTime: Date; + tickSeconds: number; +}): Promise => { + if (GAME_TICKS_PER_TURN % options.tickSeconds !== 0) { + throw new Error(`Final clock rate cannot represent an integer tick: ${options.tickSeconds}.`); + } + const outbox = await options.db.clockProjectionOutbox.findFirstOrThrow({ + where: { suspensionId: options.suspensionId, status: 'PENDING' }, + orderBy: { id: 'asc' }, + }); + const payload = + outbox.payload && typeof outbox.payload === 'object' && !Array.isArray(outbox.payload) + ? { ...(outbox.payload as Record) } + : null; + if (!payload || payload.suspensionId !== options.suspensionId) { + throw new Error('Unification clock projection outbox payload is invalid.'); + } + payload.clockBaseTime = options.clockBaseTime.toISOString(); + payload.ticksPerSecond = GAME_TICKS_PER_TURN / options.tickSeconds; + await options.db.clockProjectionOutbox.update({ + where: { id: outbox.id }, + data: { payload: asJson(payload), checksum: checksum(payload) }, + }); +}; + +export const reconcileClockSuspension = async (options: { + db: GamePrismaClient; + suspensionId: string; + authority: ClockOperationAuthority; + /** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */ + testResumeWallAt?: Date; +}): Promise => + runSerializableClockOperation(() => + options.db.$transaction( + async (db) => { + await verifyAuthority(db, options.authority); + await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK); + await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK); + return reconcileClockSuspensionInTransaction({ + db, + suspensionId: options.suspensionId, + profileName: options.authority.profileName, + authority: options.authority, + ...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}), + }); + }, + { isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 } + ) + ); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 2b70fc96..daa4f049 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(), }); @@ -132,6 +131,15 @@ const zMessageRespond = z.object({ response: z.boolean(), }); +const zSyncDiplomaticResponse = z.object({ + type: z.literal('syncDiplomaticResponse'), + userId: z.string().min(1), + generalId: z.number().int().positive(), + messageId: z.number().int().positive(), + nationIds: z.array(z.number().int().positive()).max(4), + cityIds: z.array(z.number().int().positive()).max(256), +}); + const zVacation = z.object({ type: z.literal('vacation'), userId: z.string().min(1), @@ -427,7 +435,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(); @@ -610,6 +618,14 @@ const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope) return { ...command, requestId: envelope.requestId }; }; +const normalizeSyncDiplomaticResponse: CommandNormalizer<'syncDiplomaticResponse'> = (envelope) => { + const command = parseWith(zSyncDiplomaticResponse, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => { const command = parseWith(zVacation, envelope.command); if (!command) { @@ -860,6 +876,7 @@ const normalizers: CommandNormalizerMap = { buildNationCandidate: normalizeBuildNationCandidate, instantRetreat: normalizeInstantRetreat, messageRespond: normalizeMessageRespond, + syncDiplomaticResponse: normalizeSyncDiplomaticResponse, vacation: normalizeVacation, setMySetting: normalizeSetMySetting, dropItem: normalizeDropItem, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index b9bbdefb..e6adbdd3 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,11 +1,13 @@ import { acquireGameSchemaAdvisoryXactLock, + CLOCK_OPERATION_PERSISTENCE_LOCK, createGamePostgresConnector, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma, writeReadModelChangeJournal, enqueuePrivateMessageWebPush, enqueueWebPushOutboxEvents, + persistMessageEnvelope, type InputJsonValue, type ReadModelJournalWriteResult, type TurnEngineCityUpdateInput, @@ -55,12 +57,22 @@ import { persistUnificationFinalization } from './unificationPersistence.js'; import { buildOldNationArchiveData } from './oldNationArchive.js'; import { persistYearbookSnapshot } from './yearbookPersistence.js'; import { buildTurnWebPushEvents, captureWebPushTurnBaseline } from './webPushEvents.js'; +import { + persistClockSuspensionLedgerUnderHeldLocks, + prepareClockSuspensionUnderHeldLocks, + readClockDatabaseWall, + refreshClockProjectionForFinalClockUnderHeldLocks, +} from './clockReconciliation.js'; +import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js'; +import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; takeCommittedReadModelChanges(): RealtimeReadModelChanges | null; takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null; close(): Promise; + applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise; + synchronizeClockAuthority(): Promise; } export interface CommittedReadModelChangeReceipt { @@ -122,6 +134,10 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([ 'clock_base_time', 'clockMode', 'clock_mode', + 'clockPhase', + 'clock_phase', + 'clockRevision', + 'clock_revision', 'clockTick', 'clock_tick', 'clockWallAnchor', @@ -135,6 +151,8 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([ 'last_turn_tick', 'lastTurnTime', 'last_turn_time', + 'deadlineGeneration', + 'deadline_generation', 'lease', 'leaseOwner', 'lease_owner', @@ -1122,9 +1140,19 @@ export const createDatabaseTurnHooks = async ( clockMode: state.clockMode ?? 'manual', clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime, lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)), + clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'), + clockRevision: BigInt(state.clockRevision ?? 1), + deadlineGeneration: BigInt(state.deadlineGeneration ?? 1), config: asJson(world.getWorldConfig()), meta: asJson(state.meta), }; + const writesGeneralAccess = + accessScoreResetGeneralIds.length > 0 || + lifecycleEvents.length > 0 || + deletedGenerals.length > 0 || + generals.some( + (general) => typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal) + ); const persist = async ( prisma: GamePrisma.TransactionClient ): Promise<{ @@ -1157,6 +1185,129 @@ export const createDatabaseTurnHooks = async ( // world mutation. A stale daemon can finish calculating, but it can // never commit after another owner has advanced the epoch. await options?.turnDaemonLease?.assertActive(prisma); + await acquireGameSchemaAdvisoryXactLock(prisma, CLOCK_OPERATION_PERSISTENCE_LOCK); + if (writesGeneralAccess) { + // General-access API writers take this lock before touching + // world rows. Clock operations use the same global order. + await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK); + } + const persistedClock = await prisma.$queryRaw< + Array<{ + clock_phase: string; + clock_revision: bigint; + deadline_generation: bigint; + clock_initialized: boolean; + opening_reached: boolean; + }> + >(GamePrisma.sql` + SELECT clock_phase, + clock_revision, + deadline_generation, + clock_base_time IS NOT NULL + AND clock_tick IS NOT NULL + AND clock_wall_anchor IS NOT NULL + AND last_turn_tick IS NOT NULL AS clock_initialized, + clock_wall_anchor <= CURRENT_TIMESTAMP AS opening_reached + FROM world_state + WHERE id = ${state.id} + FOR UPDATE + `); + const durableClock = persistedClock[0]; + if (!durableClock) { + throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`); + } + const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'); + const expectedRevision = BigInt(state.clockRevision ?? 1); + const expectedGeneration = BigInt(state.deadlineGeneration ?? 1); + // Match worldLoader's dual-read boundary: a legacy row is not an + // authoritative RUNNING clock merely because the newly-added phase + // column has its database default. Its first fenced flush installs + // the complete MANUAL snapshot atomically. + const durablePhase = durableClock.clock_initialized ? durableClock.clock_phase : 'MANUAL'; + const stateMeta = asRecord(state.meta); + const unificationSuspensionId = + typeof stateMeta.unificationClockSuspensionId === 'string' + ? stateMeta.unificationClockSuspensionId + : null; + const openingPhaseTransition = + durableClock.clock_initialized && + durablePhase === 'PREOPEN' && + expectedPhase === 'RUNNING' && + durableClock.opening_reached; + const unificationSuspensionTransition = + durableClock.clock_initialized && + durablePhase === 'RUNNING' && + expectedPhase === 'SUSPENDED' && + Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) === 2 && + Boolean(unificationSuspensionId); + const completionPhaseTransition = + durableClock.clock_initialized && + durablePhase === 'RUNNING' && + expectedPhase === 'COMPLETED' && + Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) >= 2; + if ( + (!openingPhaseTransition && + !unificationSuspensionTransition && + !completionPhaseTransition && + durablePhase !== expectedPhase) || + durableClock.clock_revision !== expectedRevision || + durableClock.deadline_generation !== expectedGeneration + ) { + throw new Error( + `Game clock fence changed before flush: expected ${expectedPhase}@${expectedRevision}/${expectedGeneration}, ` + + `found ${durablePhase}@${durableClock.clock_revision}/${durableClock.deadline_generation}.` + ); + } + const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null; + const unificationCutTick = unificationCutWallAt + ? world.dateToGameTick(world.getGameNow(unificationCutWallAt)) + : null; + const suspensionPreparation = + unificationCutTick !== null + ? await prepareClockSuspensionUnderHeldLocks({ + db: prisma, + cutTick: unificationCutTick, + cutWallAt: unificationCutWallAt!, + }) + : null; + if (commandCompletion) { + const commandFence = await prisma.$queryRaw< + Array<{ + status: string; + processing_clock_revision: bigint | null; + processing_deadline_generation: bigint | null; + }> + >(GamePrisma.sql` + SELECT status, + processing_clock_revision, + processing_deadline_generation + FROM input_event + WHERE request_id = ${commandCompletion.requestId} + AND target = 'ENGINE'::"InputEventTarget" + FOR UPDATE + `); + const event = commandFence[0]; + const unificationRevisionTransition = + commandCompletion.result.type === 'messageRespond' && + commandCompletion.result.ok && + commandCompletion.result.action === 'raiseInvader' && + expectedPhase === 'RECONCILING' && + event?.processing_clock_revision !== null && + event?.processing_deadline_generation !== null && + event?.processing_clock_revision + 1n === expectedRevision && + event?.processing_deadline_generation + 1n === expectedGeneration; + if ( + !event || + event.status !== 'PROCESSING' || + (!unificationRevisionTransition && + (event.processing_clock_revision !== expectedRevision || + event.processing_deadline_generation !== expectedGeneration)) + ) { + throw new Error( + `Input event processing clock fence changed before commit: ${commandCompletion.requestId}.` + ); + } + } let neutralAuctionsToCreate = pendingNeutralAuctions; if (pendingNeutralAuctions.length > 0) { const latestRegistrationKey = @@ -1261,6 +1412,35 @@ export const createDatabaseTurnHooks = async ( END WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL `); + await prisma.$executeRaw(GamePrisma.sql` + UPDATE select_pool + SET reserved_until = CASE + WHEN reserved_until_tick IS NULL THEN reserved_until + ELSE CAST(${baseTime} AS timestamp) + + (reserved_until_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((reserved_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) + * INTERVAL '1 millisecond' + END + WHERE reserved_until_tick IS NOT NULL + `); + await prisma.$executeRaw(GamePrisma.sql` + UPDATE select_npc_token + SET valid_until = CASE + WHEN valid_until_tick IS NULL THEN valid_until + ELSE CAST(${baseTime} AS timestamp) + + (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) + * INTERVAL '1 millisecond' + END, + pick_more_from = CASE + WHEN pick_more_from_tick IS NULL THEN pick_more_from + ELSE CAST(${baseTime} AS timestamp) + + (pick_more_from_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((pick_more_from_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) + * INTERVAL '1 millisecond' + END + WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL + `); } for (const betting of pendingNationBettingOpens) { @@ -1323,20 +1503,6 @@ export const createDatabaseTurnHooks = async ( const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle'); const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle'); - const writesGeneralAccess = - accessScoreResetGeneralIds.length > 0 || - lifecycleEvents.length > 0 || - deletedGenerals.length > 0 || - generals.some( - (general) => - typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal) - ); - if (writesGeneralAccess) { - // API access writers acquire this before traffic/access rows. - // Match that order before lifecycle and monthly score writes. - await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK); - } - await persistInheritancePointAdjustments(beforeLifecycleAdjustments); await persistInheritanceLogs(beforeLifecycleLogs); await persistGeneralLifecycleEvents( @@ -1699,37 +1865,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; @@ -1742,6 +1889,41 @@ export const createDatabaseTurnHooks = async ( if (options?.reservedTurns && persistedReservedTurnChanges) { await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges); } + if (suspensionPreparation && unificationSuspensionId) { + const cutTick = unificationCutTick!; + await prisma.worldState.update({ + where: { id: state.id }, + data: { + clockTick: BigInt(cutTick), + clockWallAnchor: suspensionPreparation.cutWallAt, + }, + }); + await persistClockSuspensionLedgerUnderHeldLocks({ + db: prisma, + suspensionId: unificationSuspensionId, + worldStateId: state.id, + profileName: options?.profileName ?? 'default', + source: 'UNIFICATION_WAIT', + cutTick, + cutWallAt: suspensionPreparation.cutWallAt, + rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds, + sourceRevision: state.clockRevision ?? 1, + }); + } + if ( + commandCompletion?.result.type === 'messageRespond' && + commandCompletion.result.ok && + commandCompletion.result.action === 'raiseInvader' && + unificationSuspensionId && + state.clockPhase === 'RECONCILING' + ) { + await refreshClockProjectionForFinalClockUnderHeldLocks({ + db: prisma, + suspensionId: unificationSuspensionId, + clockBaseTime: state.clockBaseTime ?? state.lastTurnTime, + tickSeconds: state.tickSeconds, + }); + } if (commandCompletion) { await prisma.inputEvent.update({ where: { requestId: commandCompletion.requestId }, @@ -1827,6 +2009,11 @@ export const createDatabaseTurnHooks = async ( }, executeCommand: async (requestId, execute) => { const committed = await prisma.$transaction(async (transaction) => { + await options?.turnDaemonLease?.assertActive(transaction); + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK); + await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world); + const leaseToken = options?.turnDaemonLease?.getToken(); const directLogFloor = ( await transaction.logEntry.findFirst({ @@ -1834,7 +2021,19 @@ export const createDatabaseTurnHooks = async ( select: { id: true }, }) )?.id ?? 0; - const result = await execute({ db: transaction }); + const result = await execute({ + db: transaction, + ...(leaseToken + ? { + clockOperationAuthority: { + kind: 'DAEMON' as const, + profileName: leaseToken.profile, + ownerId: leaseToken.ownerId, + fencingEpoch: leaseToken.fencingEpoch, + }, + } + : {}), + }); const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor); return { result, persisted }; }, transactionOptions); @@ -1850,6 +2049,20 @@ export const createDatabaseTurnHooks = async ( return takeCommittedReceipt()?.changes ?? null; }, takeCommittedReadModelChangeReceipt: takeCommittedReceipt, + applyClockProjection: async (redis, workerId) => { + await applyNextClockProjection({ db: prisma, redis, workerId }); + const clock = await prisma.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { clockPhase: true }, + }); + return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL'; + }, + synchronizeClockAuthority: () => + prisma.$transaction(async (transaction) => { + await options?.turnDaemonLease?.assertActive(transaction); + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + return synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world); + }, transactionOptions), close: () => connector.disconnect(), }; }; 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..0b625325 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'; @@ -15,6 +17,7 @@ export interface GatewayProfileGate { } const DEFAULT_CACHE_MS = 2000; +const PROFILE_STATUSES_MARKABLE_AS_PAUSED = ['PREOPEN', 'RUNNING', 'PAUSED'] as const; export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise => { const connector = createGatewayPostgresConnector({ @@ -42,7 +45,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; } @@ -53,8 +56,11 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption async markPaused(error?: unknown): Promise { const message = error instanceof Error ? error.message : error ? String(error) : null; try { - await prisma.gatewayProfile.update({ - where: { profileName: options.profileName }, + await prisma.gatewayProfile.updateMany({ + where: { + profileName: options.profileName, + status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] }, + }, data: { status: 'PAUSED', lastError: message, diff --git a/app/game-engine/src/turn/inMemoryStateStore.ts b/app/game-engine/src/turn/inMemoryStateStore.ts index 2a0722b9..005493bc 100644 --- a/app/game-engine/src/turn/inMemoryStateStore.ts +++ b/app/game-engine/src/turn/inMemoryStateStore.ts @@ -35,13 +35,27 @@ export class InMemoryTurnStateStore implements TurnStateStore { return asNumber(meta.isunited ?? meta.isUnited, 0) >= 2; } - async loadGameClock(wallNow = new Date(Date.now())): Promise<{ mode: 'realtime' | 'manual'; now: Date }> { + async loadGameClock(wallNow = new Date(Date.now())): Promise<{ + mode: 'realtime' | 'manual'; + now: Date; + phase: ReturnType['phase']; + revision: number; + deadlineGeneration: number; + }> { + const state = this.world.getGameClockState(); return { - mode: this.world.getGameClockState().mode, + mode: state.mode, now: this.world.getGameNow(wallNow), + phase: state.phase, + revision: state.revision, + deadlineGeneration: state.deadlineGeneration, }; } + async promotePreopenAtOpening(wallNow: Date): Promise { + return this.world.promotePreopenAtOpening(wallNow); + } + async rebaseRealtimeBacklog(wallNow: Date) { return this.world.rebaseRealtimeBacklog(wallNow); } 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/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index bb605ce9..1ac2ea93 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -10,7 +10,14 @@ import type { UnitSetDefinition, } from '@sammo-ts/logic'; import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic'; -import { GAME_TICKS_PER_TURN, GameClock, type GameClockMode } from '@sammo-ts/common'; +import { + GAME_TICKS_PER_TURN, + GameClock, + assertGameplayCommitAllowed, + inferClockPhase, + type GameClockMode, + type GameClockPhase, +} from '@sammo-ts/common'; import type { TurnCheckpoint } from '../lifecycle/types.js'; import type { @@ -123,6 +130,23 @@ export interface InMemoryGameClockState { mode: GameClockMode; wallAnchor: Date; lastTurnTick: number; + phase: GameClockPhase; + revision: number; + deadlineGeneration: number; +} + +export interface DurableGameClockSnapshot extends InMemoryGameClockState { + lastTurnTick: number; +} + +export interface DurableClockReconciliationAlignment { + suspensionId: string; + sourceRevision: number; + targetRevision: number; + deadlineGeneration: number; + alignedTick: number; + shiftTicks: number; + resumeWallAt: Date; } export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle'; @@ -525,6 +549,9 @@ export class InMemoryTurnWorld { constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) { const baseTime = new Date((state.clockBaseTime ?? state.lastTurnTime).getTime()); const mode = state.clockMode ?? 'manual'; + const phase = state.clockPhase ?? inferClockPhase(mode); + const revision = state.clockRevision ?? 1; + const deadlineGeneration = state.deadlineGeneration ?? 1; const wallAnchor = new Date((state.clockWallAnchor ?? state.lastTurnTime).getTime()); const bootstrapClock = new GameClock({ baseTime, @@ -532,6 +559,8 @@ export class InMemoryTurnWorld { mode, wallAnchor, turnSeconds: state.tickSeconds, + phase, + revision, }); const lastTurnTick = state.lastTurnTick ?? bootstrapClock.dateToTick(state.lastTurnTime); const clockTick = state.clockTick ?? lastTurnTick; @@ -541,6 +570,8 @@ export class InMemoryTurnWorld { mode, wallAnchor, turnSeconds: state.tickSeconds, + phase, + revision, }); const lastTurnTime = gameClock.tickToDate(lastTurnTick); this.state = { @@ -550,6 +581,9 @@ export class InMemoryTurnWorld { clockMode: mode, clockWallAnchor: wallAnchor, lastTurnTick, + clockPhase: phase, + clockRevision: revision, + deadlineGeneration, lastTurnTime, meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() }, }; @@ -621,6 +655,8 @@ export class InMemoryTurnWorld { mode: this.state.clockMode ?? 'manual', wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, turnSeconds: this.state.tickSeconds, + phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), + revision: this.state.clockRevision ?? 1, }); } @@ -649,6 +685,9 @@ export class InMemoryTurnWorld { mode: this.state.clockMode ?? 'manual', wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()), lastTurnTick: this.state.lastTurnTick ?? 0, + phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), + revision: this.state.clockRevision ?? 1, + deadlineGeneration: this.state.deadlineGeneration ?? 1, }; } @@ -656,11 +695,193 @@ export class InMemoryTurnWorld { return this.getGameClock().now(wallNow); } + promotePreopenAtOpening(wallNow: Date): boolean { + const clock = this.getGameClock(); + if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) { + return false; + } + if (clock.tick !== 0) { + throw new Error(`PREOPEN opening invariant requires clock tick zero, found ${clock.tick}.`); + } + this.state = { + ...this.state, + clockPhase: 'RUNNING', + }; + return true; + } + + beginUnificationWait(suspensionId: string): void { + const phase = this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'); + if (phase === 'SUSPENDED' && this.state.meta.unificationClockSuspensionId === suspensionId) { + return; + } + if (phase !== 'RUNNING') { + throw new Error(`UNIFICATION_WAIT can start only from RUNNING; current phase is ${phase}.`); + } + this.state = { + ...this.state, + clockPhase: 'SUSPENDED', + meta: { + ...this.state.meta, + unificationClockSuspensionId: suspensionId, + }, + }; + } + + completeGameClock(): void { + const phase = this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'); + if (phase === 'COMPLETED') return; + if (phase !== 'RUNNING') { + throw new Error(`Game clock can complete only from RUNNING; current phase is ${phase}.`); + } + this.state = { ...this.state, clockPhase: 'COMPLETED' }; + } + + private applyClockReconciliationAlignment(input: DurableClockReconciliationAlignment): void { + if ( + !Number.isSafeInteger(input.alignedTick) || + !Number.isSafeInteger(input.shiftTicks) || + input.shiftTicks < 0 || + !Number.isSafeInteger(input.sourceRevision) || + !Number.isSafeInteger(input.targetRevision) || + input.targetRevision !== input.sourceRevision + 1 || + !Number.isSafeInteger(input.deadlineGeneration) + ) { + throw new Error('In-memory clock reconciliation received an unsafe coordinate.'); + } + const clock = this.getGameClock(); + const shiftedMilliseconds = Math.trunc((input.shiftTicks * 1_000) / clock.ticksPerSecond); + if (!Number.isSafeInteger(shiftedMilliseconds)) { + throw new Error('In-memory clock reconciliation projection delta is unsafe.'); + } + const lastTurnTick = clock.addTicks(this.state.lastTurnTick ?? 0, input.shiftTicks); + const lastTurnTime = clock.tickToDate(lastTurnTick); + this.state = { + ...this.state, + clockTick: input.alignedTick, + clockWallAnchor: new Date(input.resumeWallAt.getTime()), + lastTurnTick, + lastTurnTime, + clockPhase: 'RECONCILING', + clockRevision: input.targetRevision, + deadlineGeneration: input.deadlineGeneration, + meta: { + ...this.state.meta, + lastTurnTime: lastTurnTime.toISOString(), + turntime: shiftGameClockMetaDate(this.state.meta.turntime, shiftedMilliseconds), + starttime: shiftGameClockMetaDate(this.state.meta.starttime, shiftedMilliseconds), + tnmt_time: shiftGameClockMetaDate(this.state.meta.tnmt_time, shiftedMilliseconds), + }, + }; + for (const [generalId, general] of this.generals) { + const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks); + this.generals.set(generalId, { + ...general, + turnTick, + turnTime: clock.tickToDate(turnTick), + }); + } + for (const entry of this.generalPoolEntries ?? []) { + if (entry.reservedUntilTick !== null) { + entry.reservedUntilTick = clock.addTicks(entry.reservedUntilTick, input.shiftTicks); + entry.reservedUntil = clock.tickToDate(entry.reservedUntilTick); + } else if (entry.reservedUntil) { + entry.reservedUntil = new Date(entry.reservedUntil.getTime() + shiftedMilliseconds); + } + } + for (const auction of this.pendingNeutralAuctions) { + auction.closeAt = new Date(auction.closeAt.getTime() + shiftedMilliseconds); + } + if (this.checkpoint) { + const checkpointTick = clock.addTicks( + this.checkpoint.turnTick ?? clock.dateToTick(new Date(this.checkpoint.turnTime)), + input.shiftTicks + ); + this.checkpoint = { + ...this.checkpoint, + turnTick: checkpointTick, + turnTime: clock.tickToDate(checkpointTick).toISOString(), + }; + } + } + + applyClockReconciliation(input: Omit): void { + const phase = this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'); + if (phase !== 'SUSPENDED' || this.state.meta.unificationClockSuspensionId !== input.suspensionId) { + throw new Error('In-memory clock reconciliation requires the matching UNIFICATION_WAIT suspension.'); + } + this.applyClockReconciliationAlignment({ + ...input, + sourceRevision: this.state.clockRevision ?? 1, + }); + } + + applyDurableClockReconciliation(input: DurableClockReconciliationAlignment): void { + const clock = this.getGameClockState(); + if (clock.revision === input.targetRevision && clock.phase === 'RECONCILING') { + return; + } + if (clock.revision !== input.sourceRevision) { + throw new Error( + `Durable clock reconciliation source mismatch: memory ${clock.revision}, ledger ${input.sourceRevision}.` + ); + } + if (clock.phase !== 'RUNNING' && clock.phase !== 'SUSPENDED') { + throw new Error(`Durable clock reconciliation cannot apply from in-memory phase ${clock.phase}.`); + } + this.applyClockReconciliationAlignment(input); + } + + synchronizeDurableClockSnapshot(input: DurableGameClockSnapshot): void { + const current = this.getGameClockState(); + if (current.revision !== input.revision || current.deadlineGeneration !== input.deadlineGeneration) { + throw new Error( + `Durable clock snapshot generation mismatch: memory ${current.revision}/${current.deadlineGeneration}, ` + + `database ${input.revision}/${input.deadlineGeneration}.` + ); + } + if ((this.state.lastTurnTick ?? 0) !== input.lastTurnTick) { + throw new Error( + `Durable clock snapshot turn cursor mismatch: memory ${this.state.lastTurnTick ?? 0}, database ${input.lastTurnTick}.` + ); + } + const currentBaseTime = this.state.clockBaseTime ?? this.state.lastTurnTime; + if (currentBaseTime.getTime() !== input.baseTime.getTime()) { + throw new Error( + `Durable clock snapshot base mismatch: memory ${currentBaseTime.toISOString()}, ` + + `database ${input.baseTime.toISOString()}.` + ); + } + const validTransition = + current.phase === input.phase || + (current.phase === 'RUNNING' && input.phase === 'SUSPENDED') || + (current.phase === 'RECONCILING' && input.phase === 'RUNNING'); + if (!validTransition) { + throw new Error(`Durable clock snapshot phase mismatch: memory ${current.phase}, database ${input.phase}.`); + } + this.state = { + ...this.state, + clockBaseTime: new Date(input.baseTime.getTime()), + clockTick: input.tick, + clockMode: input.mode, + clockWallAnchor: new Date(input.wallAnchor.getTime()), + clockPhase: input.phase, + clockRevision: input.revision, + deadlineGeneration: input.deadlineGeneration, + }; + } + + completeClockReconciliation(): void { + if (this.state.clockPhase === 'RECONCILING') { + this.state = { ...this.state, clockPhase: 'RUNNING' }; + } + } + getRunnableGameNow(wallNow: Date): Date { const clock = this.getGameClock(); // PREOPEN still needs negative game ticks for cooldowns, but executable // turn schedules must not precede the wall-clock opening boundary. - if (clock.mode === 'realtime' && wallNow.getTime() < clock.wallAnchor.getTime()) { + if (clock.phase === 'PREOPEN') { return clock.now(clock.wallAnchor); } return clock.now(wallNow); @@ -676,6 +897,7 @@ export class InMemoryTurnWorld { advanceGameClockTo(target: Date, wallNow: Date): void { const clock = this.getGameClock(); + assertGameplayCommitAllowed(clock.phase); const targetTick = clock.dateToTick(target); // Realtime의 권위 시각은 wall anchor 이후 경과입니다. 밀린 턴을 과거 // target으로 처리한 완료 시각에 anchor를 다시 고정하면, 처리에 걸린 @@ -696,7 +918,7 @@ export class InMemoryTurnWorld { skippedTurns: number; } | null { const clock = this.getGameClock(); - if (clock.mode !== 'realtime') { + if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') { return null; } const turnMinutes = Math.max(1, Math.round(this.state.tickSeconds / 60)); @@ -969,6 +1191,8 @@ export class InMemoryTurnWorld { mode: this.state.clockMode ?? 'manual', wallAnchor: anchorWall, turnSeconds: nextTickSeconds, + phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), + revision: this.state.clockRevision ?? 1, }); const lastTurnTick = this.state.lastTurnTick ?? previousClock.dateToTick(this.state.lastTurnTime); const nextLastTurnTime = nextClock.tickToDate(lastTurnTick); @@ -1470,6 +1694,8 @@ export class InMemoryTurnWorld { mode: this.state.clockMode ?? 'manual', wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, turnSeconds: this.state.tickSeconds, + phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), + revision: this.state.clockRevision ?? 1, }); const nextLastTurnTime = shiftedClock.tickToDate(this.state.lastTurnTick ?? 0); const nextMeta = { @@ -1605,6 +1831,7 @@ export class InMemoryTurnWorld { } executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution { + assertGameplayCommitAllowed(this.getGameClock().phase); const currentGeneral = this.generals.get(general.id) ?? general; const executionYear = this.state.currentYear; const executionMonth = this.state.currentMonth; @@ -1787,6 +2014,7 @@ export class InMemoryTurnWorld { } async advanceMonth(turnTime: Date): Promise { + assertGameplayCommitAllowed(this.getGameClock().phase); const previousYear = this.state.currentYear; const previousMonth = this.state.currentMonth; let nextYear = previousYear; 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/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index 9e97dd88..f60f9dde 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -133,6 +133,7 @@ export const createRaiseInvaderHandler = (options: { env: TurnCommandEnv; loadArchivedNationMaxId?: (serverId: string) => Promise; maxGeneralsPerMinute?: number; + clockWallNow?: Date; }): MonthlyEventActionHandler => { return async (args, environment) => { const world = options.getWorld(); @@ -166,7 +167,7 @@ export const createRaiseInvaderHandler = (options: { (candidate) => totalGeneralCount <= maxGeneralsPerMinute * candidate ); if (nextTerm !== undefined) { - world.changeTurnTerm(nextTerm); + world.changeTurnTerm(nextTerm, options.clockWallNow); // Reprojection preserves the frozen monthly boundary by tick but // changes its displayed Date. New generals must join that frozen // boundary, not the realtime game clock that kept advancing while @@ -548,6 +549,7 @@ export const createInvaderEndingHandler = (options: { isUnited: 3, refreshLimit: readNumber(meta.refreshLimit) * 100, }); + world.completeGameClock(); world.removeEvent(environment.currentEventID); }; }; 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/runtimeClockAuthoritySync.ts b/app/game-engine/src/turn/runtimeClockAuthoritySync.ts new file mode 100644 index 00000000..d3d96647 --- /dev/null +++ b/app/game-engine/src/turn/runtimeClockAuthoritySync.ts @@ -0,0 +1,125 @@ +import { parseGameClockPhase } from '@sammo-ts/common'; +import type { GamePrisma } from '@sammo-ts/infra'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; + +const safeNumber = (value: bigint, label: string): number => { + const result = Number(value); + if (!Number.isSafeInteger(result)) { + throw new Error(`${label} is outside the JavaScript safe integer range: ${value}.`); + } + return result; +}; + +/** + * Replays durable suspension ledgers into the already-running daemon before it + * handles a command or resumes scheduled turns. The caller must hold the clock + * operation advisory lock so the world row and ledger chain are one snapshot. + */ +export const synchronizeRuntimeClockAuthorityUnderHeldLock = async ( + db: GamePrisma.TransactionClient, + world: InMemoryTurnWorld +): Promise => { + const durable = await db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { + id: true, + clockBaseTime: true, + clockTick: true, + clockMode: true, + clockWallAnchor: true, + lastTurnTick: true, + clockPhase: true, + clockRevision: true, + deadlineGeneration: true, + }, + }); + if ( + !durable || + !durable.clockBaseTime || + durable.clockTick === null || + !durable.clockWallAnchor || + durable.lastTurnTick === null + ) { + throw new Error('Runtime clock synchronization requires one fully initialized world clock.'); + } + + const before = world.getGameClockState(); + const durableRevision = safeNumber(durable.clockRevision, 'durable clock revision'); + const durableGeneration = safeNumber(durable.deadlineGeneration, 'durable deadline generation'); + if (before.revision > durableRevision) { + throw new Error(`In-memory clock revision ${before.revision} is ahead of durable revision ${durableRevision}.`); + } + + if (before.revision < durableRevision) { + const ledgers = await db.clockSuspension.findMany({ + where: { + worldStateId: durable.id, + sourceRevision: { gte: BigInt(before.revision) }, + targetRevision: { lte: durable.clockRevision }, + status: { in: ['RECONCILING', 'APPLIED'] }, + }, + orderBy: { sourceRevision: 'asc' }, + select: { + id: true, + sourceRevision: true, + targetRevision: true, + shiftTicks: true, + alignedTick: true, + resumeWallAt: true, + }, + }); + let expectedRevision = before.revision; + let expectedGeneration = before.deadlineGeneration; + for (const ledger of ledgers) { + const sourceRevision = safeNumber(ledger.sourceRevision, `clock suspension ${ledger.id} source revision`); + const targetRevision = safeNumber(ledger.targetRevision, `clock suspension ${ledger.id} target revision`); + if (sourceRevision !== expectedRevision || targetRevision !== sourceRevision + 1) { + throw new Error( + `Clock suspension ledger chain is discontinuous at ${ledger.id}: ` + + `expected ${expectedRevision}->${expectedRevision + 1}, found ${sourceRevision}->${targetRevision}.` + ); + } + if (ledger.shiftTicks === null || ledger.alignedTick === null || !ledger.resumeWallAt) { + throw new Error(`Clock suspension ${ledger.id} has no completed reconciliation coordinate.`); + } + expectedGeneration += 1; + world.applyDurableClockReconciliation({ + suspensionId: ledger.id, + sourceRevision, + targetRevision, + deadlineGeneration: expectedGeneration, + alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`), + shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`), + resumeWallAt: ledger.resumeWallAt, + }); + expectedRevision = targetRevision; + } + if (expectedRevision !== durableRevision || expectedGeneration !== durableGeneration) { + throw new Error( + `Clock suspension ledger chain ended at ${expectedRevision}/${expectedGeneration}, ` + + `but durable clock is ${durableRevision}/${durableGeneration}.` + ); + } + } + + world.synchronizeDurableClockSnapshot({ + baseTime: durable.clockBaseTime, + tick: safeNumber(durable.clockTick, 'durable clock tick'), + mode: durable.clockMode === 'manual' ? 'manual' : 'realtime', + wallAnchor: durable.clockWallAnchor, + lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'), + phase: parseGameClockPhase(durable.clockPhase), + revision: durableRevision, + deadlineGeneration: durableGeneration, + }); + + const after = world.getGameClockState(); + return ( + before.phase !== after.phase || + before.revision !== after.revision || + before.deadlineGeneration !== after.deadlineGeneration || + before.tick !== after.tick || + before.wallAnchor.getTime() !== after.wallAnchor.getTime() + ); +}; diff --git a/app/game-engine/src/turn/runtimeClockShift.ts b/app/game-engine/src/turn/runtimeClockShift.ts index 5b730e81..43db1a28 100644 --- a/app/game-engine/src/turn/runtimeClockShift.ts +++ b/app/game-engine/src/turn/runtimeClockShift.ts @@ -1,5 +1,6 @@ -import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; +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 { @@ -137,13 +138,20 @@ const ensureEngineCommand = async ( deltaMinutes, }; try { - await db.inputEvent.create({ - data: { - requestId, - target: 'ENGINE', - eventType: command.type, - payload: asJson(command), - }, + await db.$transaction(async (transaction) => { + const coordinate = await readInputEventClockCoordinate(transaction); + await transaction.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: command.type, + payload: asJson(command), + acceptedGameTick: coordinate.gameTick, + acceptedClockRevision: coordinate.clockRevision, + acceptedDeadlineGeneration: coordinate.deadlineGeneration, + createdAt: coordinate.wallAt, + }, + }); }); } catch (error) { if (!isUniqueConflict(error)) { diff --git a/app/game-engine/src/turn/runtimeGameSettings.ts b/app/game-engine/src/turn/runtimeGameSettings.ts index 43a186e9..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, @@ -10,7 +11,7 @@ import { type TurnDaemonCommand, type TurnDaemonCommandResult, } from '@sammo-ts/common'; -import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; +import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js'; @@ -97,13 +98,20 @@ const ensureEngineCommand = async ( settings, }; try { - await db.inputEvent.create({ - data: { - requestId, - target: 'ENGINE', - eventType: command.type, - payload: asJson(command), - }, + await db.$transaction(async (transaction) => { + const coordinate = await readInputEventClockCoordinate(transaction); + await transaction.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: command.type, + payload: asJson(command), + acceptedGameTick: coordinate.gameTick, + acceptedClockRevision: coordinate.clockRevision, + acceptedDeadlineGeneration: coordinate.deadlineGeneration, + createdAt: coordinate.wallAt, + }, + }); }); } catch (error) { if (!isUniqueConflict(error)) throw error; @@ -151,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 7ad581db..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,15 +438,15 @@ export const reserveSelectionPool = async (options: { worldState: WorldStateRow; userId: string; now?: Date; - acceptedGameTick?: 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); - if (!Number.isSafeInteger(acceptedGameTick)) { - fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.'); + const processingGameTick = options.processingGameTick; + if (!Number.isSafeInteger(processingGameTick)) { + fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.'); } await lockSelectionUser(db, userId); await lockSelectionMutationTables(db); @@ -454,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, acceptedGameTick) + (row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, processingGameTick) ); if (existing.length > 0) { return toReservationDto(existing, Boolean(general), worldState, world); @@ -471,8 +468,8 @@ export const reserveSelectionPool = async (options: { where: { generalId: null, OR: [ - { reservedUntilTick: { lt: BigInt(acceptedGameTick) } }, - { reservedUntilTick: null, reservedUntil: { lt: now } }, + { reservedUntilTick: { lt: BigInt(processingGameTick) } }, + { reservedUntilTick: null, reservedUntil: { not: null } }, ], }, data: { @@ -483,7 +480,7 @@ export const reserveSelectionPool = async (options: { }); currentRows = await synchronizeSelectionPoolWorld(db, world); const availableIds = new Set( - world.listGeneralPoolCandidates(now, acceptedGameTick)?.map((candidate) => candidate.poolEntryId) ?? [] + world.listGeneralPoolCandidates(now, processingGameTick)?.map((candidate) => candidate.poolEntryId) ?? [] ); const available = currentRows.filter( (row) => @@ -499,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)!; @@ -507,7 +504,7 @@ export const reserveSelectionPool = async (options: { (row) => [row, calculateSelectionCandidateWeight(poolName, parseCandidate(row), true)] as [SelectPoolRow, number] ); - const reservedUntilTick = acceptedGameTick + RESERVATION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN; + const reservedUntilTick = processingGameTick + RESERVATION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN; if (!Number.isSafeInteger(reservedUntilTick)) { fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.'); } @@ -573,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({ @@ -582,7 +578,7 @@ const clearUnusedReservations = async ( OR: [ { ownerUserId: userId }, { reservedUntilTick: { lt: BigInt(nowTick) } }, - { reservedUntilTick: null, reservedUntil: { lt: now } }, + { reservedUntilTick: null, reservedUntil: { not: null } }, ], }, data: { @@ -711,6 +707,7 @@ export const createGeneralFromSelectionPool = async (options: { now?: Date; turnScheduleAt?: Date; operationalAcceptedAt: Date; + processingGameTick: number; seedOwnerIdentity?: string | number; ownerPicture?: string; ownerImageServer?: number; @@ -719,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); @@ -730,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; @@ -772,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)가 되는 // 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다. @@ -808,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, @@ -900,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, @@ -920,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, '이'); @@ -944,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); @@ -963,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; @@ -977,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, @@ -1009,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, @@ -1041,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 @@ -1069,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/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index 8da5370f..666edacc 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -14,8 +14,12 @@ interface TournamentState { openMonth: number; termSeconds: number; nextAt: string; + nextTick?: number; + clockRevision?: number; + deadlineGeneration?: number; bettingId?: number; bettingCloseAt?: string; + bettingCloseTick?: number; winnerId?: number; bettingSettled?: boolean; rewardSettled?: boolean; @@ -76,6 +80,9 @@ export const createTournamentAutoStartHandler = (options: { sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`, sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`, realtimeEventChannel: buildGameEventChannel(options.profileName), + activeClockRevisionKey: `sammo:${options.profileName}:clock:active-revision`, + deadlineGenerationKey: `sammo:${options.profileName}:clock:deadline-generation`, + clockPhaseKey: `sammo:${options.profileName}:clock:phase`, }; return { onMonthChanged: async (context) => { @@ -118,6 +125,8 @@ export const createTournamentAutoStartHandler = (options: { previousState && Number.isFinite(previousState.termSeconds) && previousState.termSeconds > 0 ? previousState.termSeconds : resolveTermSeconds(state.tickSeconds); + const nextAt = new Date(now.getTime() + termSeconds * 60_000); + const clockState = world.getGameClockState(); const nextState: TournamentState = { stage: 1, phase: 0, @@ -129,7 +138,10 @@ export const createTournamentAutoStartHandler = (options: { // Ref startTournament() passes calcTournamentTerm()'s seconds // value to DateInterval's minute field. Preserve that historical // initial enrollment delay; later tournament phases use seconds. - nextAt: new Date(now.getTime() + termSeconds * 60_000).toISOString(), + nextAt: nextAt.toISOString(), + nextTick: world.dateToGameTick(nextAt), + clockRevision: clockState.revision, + deadlineGeneration: clockState.deadlineGeneration, bettingId: typeof previousState?.bettingId === 'number' && Number.isFinite(previousState.bettingId) ? previousState.bettingId + 1 @@ -142,12 +154,26 @@ export const createTournamentAutoStartHandler = (options: { lastError: undefined, lastErrorAt: undefined, }; - await writeTournamentProjection(redis, keys, [ - { key: keys.participantsKey, value: [] }, - { key: keys.matchesKey, value: [] }, - { key: keys.bettingKey, value: [] }, - { key: keys.stateKey, value: nextState }, - ]); + await writeTournamentProjection( + redis, + keys, + [ + { key: keys.participantsKey, value: [] }, + { key: keys.matchesKey, value: [] }, + { key: keys.bettingKey, value: [] }, + { key: keys.stateKey, value: nextState }, + ], + clockState.phase === 'RUNNING' + ? { + activeRevisionKey: keys.activeClockRevisionKey, + deadlineGenerationKey: keys.deadlineGenerationKey, + phaseKey: keys.clockPhaseKey, + revision: clockState.revision, + deadlineGeneration: clockState.deadlineGeneration, + phase: 'RUNNING', + } + : undefined + ); const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0]; const emperor = world diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index a05e43e3..d4b44cb2 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic'; import { buildGameEventChannel, @@ -20,7 +22,11 @@ import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } fr import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js'; import { DatabaseTurnDaemonCommandQueue } from '../lifecycle/databaseCommandQueue.js'; import type { MapLoaderOptions } from '../scenario/mapLoader.js'; -import { createDatabaseTurnHooks, type CommittedReadModelChangeReceipt } from './databaseHooks.js'; +import { + createDatabaseTurnHooks, + type CommittedReadModelChangeReceipt, + type DatabaseTurnHooks, +} from './databaseHooks.js'; import type { GeneralTurnHandler, InMemoryTurnWorldOptions, TurnCalendarHandler } from './inMemoryWorld.js'; import { InMemoryTurnWorld } from './inMemoryWorld.js'; import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js'; @@ -219,6 +225,8 @@ const resolveRuntimeState = ( mode: state.clockMode ?? 'manual', wallAnchor: state.clockWallAnchor ?? state.lastTurnTime, turnSeconds: state.tickSeconds, + phase: state.clockPhase, + revision: state.clockRevision, }).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0), state.clockTick ?? state.lastTurnTick ?? 0, nextTickSeconds @@ -495,15 +503,40 @@ const createRealtimeRuntime = async (options: { profileName: string; hooks?: TurnDaemonHooks; takeCommittedReadModelChangeReceipt: (() => CommittedReadModelChangeReceipt | null) | null; -}): Promise<{ redisConnector: RedisConnector | null; hooks?: TurnDaemonHooks }> => { + applyClockProjection?: (redis: RedisConnector['client'], workerId: string) => Promise; + onClockProjectionApplied?: () => void; +}): Promise<{ + redisConnector: RedisConnector | null; + hooks?: TurnDaemonHooks; + stopClockProjectionWorker: () => void; +}> => { const redisConfig = resolveRedisConfig(options.redisUrl); if (!redisConfig) { - return { redisConnector: null, hooks: options.hooks }; + return { redisConnector: null, hooks: options.hooks, stopClockProjectionWorker: () => {} }; } const redisConnector = createRedisConnector(redisConfig); await redisConnector.connect(); const redisClient = redisConnector.client; + const clockProjectionWorkerId = `turn-daemon:${options.profileName}:${randomUUID()}`; + let clockProjectionInFlight = false; + let clockProjectionStopped = false; + const recoverClockProjection = async (): Promise => { + if (!options.applyClockProjection || clockProjectionInFlight || clockProjectionStopped) return; + clockProjectionInFlight = true; + try { + if (await options.applyClockProjection(redisClient, clockProjectionWorkerId)) { + options.onClockProjectionApplied?.(); + } + } finally { + clockProjectionInFlight = false; + } + }; + await recoverClockProjection().catch(() => undefined); + const clockProjectionTimer = options.applyClockProjection + ? setInterval(() => void recoverClockProjection().catch(() => undefined), 1_000) + : null; + clockProjectionTimer?.unref(); const realtimeChannel = buildGameEventChannel(options.profileName); const revisionKey = buildGameReadModelRevisionKey(options.profileName); const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName); @@ -548,6 +581,9 @@ const createRealtimeRuntime = async (options: { await basePublishEvents?.(result); }, publishCommandEvents: async (result) => { + if (result.type === 'messageRespond' && result.ok && result.action === 'raiseInvader') { + await recoverClockProjection(); + } try { const changes = options.takeCommittedReadModelChangeReceipt?.()?.changes; if (changes && hasRealtimeReadModelChanges(changes)) { @@ -567,7 +603,14 @@ const createRealtimeRuntime = async (options: { await basePublishCommandEvents?.(result); }, }; - return { redisConnector, hooks }; + return { + redisConnector, + hooks, + stopClockProjectionWorker: () => { + clockProjectionStopped = true; + if (clockProjectionTimer) clearInterval(clockProjectionTimer); + }, + }; }; const createStartedAdminActionConsumer = async (options: { @@ -670,6 +713,9 @@ const createTurnDaemonRuntimeWithLease = async ( })); let worldRef: InMemoryTurnWorld | null = null; let redisConnector: RedisConnector | null = null; + let stopClockProjectionWorker = () => {}; + let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined; + let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined; const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module])); const monthlyActionModules = await loadActionModuleBundle( @@ -874,6 +920,8 @@ const createTurnDaemonRuntimeWithLease = async ( }, }; takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt; + applyClockProjection = dbHooks.applyClockProjection; + synchronizeClockAuthority = dbHooks.synchronizeClockAuthority; close = async () => { if (auctionBidder) { await auctionBidder.close(); @@ -924,9 +972,17 @@ const createTurnDaemonRuntimeWithLease = async ( profileName: options.profileName ?? options.profile, hooks, takeCommittedReadModelChangeReceipt, + ...(applyClockProjection + ? { + applyClockProjection: (redis: RedisConnector['client'], workerId: string) => + applyClockProjection!(redis, workerId), + onClockProjectionApplied: () => world.completeClockReconciliation(), + } + : {}), }); redisConnector = realtimeRuntime.redisConnector; hooks = realtimeRuntime.hooks; + stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker; const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null; const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null; @@ -948,6 +1004,7 @@ const createTurnDaemonRuntimeWithLease = async ( const baseClose = close; close = async () => { + stopClockProjectionWorker(); await baseClose(); await neutralAuctionRegistrar.close(); if (redisConnector) { @@ -976,6 +1033,7 @@ const createTurnDaemonRuntimeWithLease = async ( maxGenerals: 200, catchUpCap: 1, }; + let lastObservedGatewayPause: boolean | null = null; const lifecycle = new TurnDaemonLifecycle( { @@ -986,7 +1044,21 @@ const createTurnDaemonRuntimeWithLease = async ( stateStore, processor, hooks, - pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false), + pauseGate: async () => { + if (turnDaemonLease?.isLost()) { + return true; + } + const gatewayPaused = (await pauseGate?.()) ?? false; + const phase = world.getGameClockState().phase; + const phaseNeedsSync = gatewayPaused + ? phase !== 'SUSPENDED' + : phase === 'SUSPENDED' || phase === 'RECONCILING'; + if (synchronizeClockAuthority && (lastObservedGatewayPause !== gatewayPaused || phaseNeedsSync)) { + await synchronizeClockAuthority(); + } + lastObservedGatewayPause = gatewayPaused; + return gatewayPaused; + }, commandHandler, commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined), // The exclusive fixture runner aborts the entire in-memory runtime diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 6a41fe27..001e6275 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -11,7 +11,7 @@ import type { WorldSnapshot, GeneralLastTurn, } from '@sammo-ts/logic'; -import type { GameClockMode } from '@sammo-ts/common'; +import type { GameClockMode, GameClockPhase } from '@sammo-ts/common'; export interface TurnWorldState { id: number; @@ -24,6 +24,9 @@ export interface TurnWorldState { clockMode?: GameClockMode; clockWallAnchor?: Date; lastTurnTick?: number; + clockPhase?: GameClockPhase; + clockRevision?: number; + deadlineGeneration?: number; meta: Record; } diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index ecdafbf8..5e85643f 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common'; import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '@sammo-ts/logic'; @@ -163,6 +165,14 @@ export const createUnificationHandler = (options: { }); } } + const sourceRevision = world.getGameClockState().revision; + const suspensionId = `unification-wait-${createHash('sha256') + .update(`${serverId}:${sourceRevision}`) + .digest('hex') + .slice(0, 32)}`; + world.beginUnificationWait(suspensionId); + } else { + world.completeGameClock(); } queueYearbookSnapshot(world, options.profileName, context.currentYear, context.currentMonth); 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 829cdef6..2a7788ca 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -29,6 +29,7 @@ import { normalizeTroopName, resolveTroopSecretPermission, resolveMessageTargetIcon, + readDiplomacyMeta, type GeneralActionModule, rollUniqueLottery, type ItemModule, @@ -146,6 +147,10 @@ const refreshActorKillturn = (world: InMemoryTurnWorld, actor: TurnGeneral): voi interface CommandHandlerContext { world: InMemoryTurnWorld; commandDb?: GamePrisma.TransactionClient; + clockOperationAuthority?: Extract< + NonNullable, + { kind: 'DAEMON' } + >; auctionFinalizer?: AuctionFinalizer; auctionBidder?: AuctionBidder; tournamentRewardFinalizer?: TournamentRewardFinalizer; @@ -153,6 +158,7 @@ interface CommandHandlerContext { reservedTurns?: InMemoryReservedTurnStore; generalActionModules?: ReadonlyArray; loadArchivedNationMaxId?: (serverId: string) => Promise; + reconcileUnificationWait?: Parameters[0]['reconcileUnificationWait']; } const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => { @@ -177,6 +183,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [ 'kick', 'appoint', 'voteReward', + 'syncDiplomaticResponse', ] as const satisfies readonly TurnDaemonCommand['type'][]; type ActorBoundGeneralCommandType = (typeof ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST)[number]; @@ -273,14 +280,12 @@ const resolveSelectionCommandAcceptedAt = async ( world: InMemoryTurnWorld, command: Extract ): Promise => { - const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); - if (command.acceptedGameTick !== undefined) { - return world.gameTickToDate(command.acceptedGameTick); + await resolveCommandAcceptedAt(db, command); + const processingGameTick = Reflect.get(command, 'processingGameTick'); + if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) { + return world.gameTickToDate(processingGameTick); } - 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 ( @@ -407,9 +412,10 @@ async function handleNpcPossessGeneral( throw new Error('NPC possession world state is missing.'); } const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); - const acceptedAt = command.acceptedGameAt - ? new Date(command.acceptedGameAt) - : ctx.world.getGameNow(operationalAcceptedAt); + const processingGameTick = Reflect.get(command, 'processingGameTick'); + if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) { + throw new Error('npcPossessGeneral requires an authoritative daemon processing game tick.'); + } try { return { type: 'npcPossessGeneral', @@ -423,7 +429,8 @@ async function handleNpcPossessGeneral( ...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}), generalId: command.generalId, tokenNonce: command.tokenNonce, - acceptedAt, + requestedAtWall: operationalAcceptedAt, + processingGameTick, })), }; } catch (error) { @@ -451,13 +458,12 @@ async function handleSelectPoolCreate( throw new Error('Selection-pool world state is missing.'); } const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); - const acceptedAt = - 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); + const processingGameTick = Reflect.get(command, 'processingGameTick'); + 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', @@ -476,6 +482,7 @@ async function handleSelectPoolCreate( now: acceptedAt, turnScheduleAt, operationalAcceptedAt, + processingGameTick, })), }; } catch (error) { @@ -514,7 +521,7 @@ async function handleSelectPoolReserve( userId: command.userId, seedOwnerIdentity: command.seedOwnerIdentity, now: acceptedAt, - ...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }), + processingGameTick: Reflect.get(command, 'processingGameTick') as number, }), }; } catch (error) { @@ -553,6 +560,7 @@ async function handleSelectPoolReselect( ownerDisplayName: command.ownerDisplayName, uniqueName: command.uniqueName, now: acceptedAt, + processingGameTick: Reflect.get(command, 'processingGameTick') as number, })), }; } catch (error) { @@ -1820,6 +1828,8 @@ async function handleMessageRespond( messageId: command.messageId, response: command.response, loadArchivedNationMaxId: ctx.loadArchivedNationMaxId, + clockOperationAuthority: ctx.clockOperationAuthority, + reconcileUnificationWait: ctx.reconcileUnificationWait, }); return { type: 'messageRespond', @@ -1831,6 +1841,72 @@ async function handleMessageRespond( }; } +async function handleSyncDiplomaticResponse( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const db = requireCommandDatabase(ctx); + const action = await db.messageAction.findUnique({ + where: { messageId: command.messageId }, + select: { status: true }, + }); + if (action?.status !== 'RESOLVED') { + return { + type: 'syncDiplomaticResponse', + ok: false, + generalId: command.generalId, + messageId: command.messageId, + nations: 0, + diplomacy: 0, + cities: 0, + reason: '해결되지 않은 외교서신은 동기화할 수 없습니다.', + }; + } + + const nationIds = [...new Set(command.nationIds)]; + const cityIds = [...new Set(command.cityIds)]; + const [nations, diplomacy, cities] = await Promise.all([ + db.nation.findMany({ where: { id: { in: nationIds } }, select: { id: true, meta: true } }), + nationIds.length === 0 + ? Promise.resolve([]) + : db.diplomacy.findMany({ + where: { srcNationId: { in: nationIds }, destNationId: { in: nationIds } }, + select: { srcNationId: true, destNationId: true, stateCode: true, term: true, meta: true }, + }), + db.city.findMany({ where: { id: { in: cityIds } }, select: { id: true, frontState: true } }), + ]); + + for (const nation of nations) { + ctx.world.updateNation(nation.id, { meta: asRecord(nation.meta) as Record }); + } + for (const entry of diplomacy) { + const parsedMeta = readDiplomacyMeta(asRecord(entry.meta)); + ctx.world.applyDiplomacyPatch({ + srcNationId: entry.srcNationId, + destNationId: entry.destNationId, + patch: { + state: entry.stateCode, + term: entry.term, + dead: parsedMeta.dead, + meta: parsedMeta.meta as Record, + }, + }); + } + for (const city of cities) { + ctx.world.updateCity(city.id, { frontState: city.frontState }); + } + + return { + type: 'syncDiplomaticResponse', + ok: true, + generalId: command.generalId, + messageId: command.messageId, + nations: nations.length, + diplomacy: diplomacy.length, + cities: cities.length, + }; +} + async function handleVacation( ctx: CommandHandlerContext, command: Extract @@ -2724,17 +2800,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 => { @@ -2769,11 +2844,11 @@ const validateVoteSelectionInTransaction = async ( const poll = rows[0]; if (!poll) return '설문조사가 없습니다.'; - const processingNow = ctx.world.getGameNow(new Date()); - const acceptedGameTick = command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow); - const acceptedGameAt = - command.acceptedGameTick === undefined ? processingNow : ctx.world.gameTickToDate(command.acceptedGameTick); - if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) { + const convertedProcessingTick = Reflect.get(command, 'processingGameTick'); + if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) { + throw new Error('voteReward requires an authoritative daemon processing game tick.'); + } + if (hasVotePollDeadlinePassed(poll, convertedProcessingTick)) { return '설문조사가 종료되었습니다.'; } @@ -3081,6 +3156,7 @@ export const createTurnDaemonCommandHandler = (options: { auctionBidder?: AuctionBidder; tournamentRewardFinalizer?: TournamentRewardFinalizer; loadArchivedNationMaxId?: (serverId: string) => Promise; + reconcileUnificationWait?: Parameters[0]['reconcileUnificationWait']; }): TurnDaemonCommandHandler => { let immediateGeneralActionExecutor: Promise | null = null; const ctx: CommandHandlerContext = { @@ -3091,6 +3167,7 @@ export const createTurnDaemonCommandHandler = (options: { reservedTurns: options.reservedTurns, generalActionModules: options.generalActionModules, loadArchivedNationMaxId: options.loadArchivedNationMaxId, + reconcileUnificationWait: options.reconcileUnificationWait, getImmediateGeneralActionExecutor: () => { immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({ world: options.world, @@ -3142,6 +3219,11 @@ export const createTurnDaemonCommandHandler = (options: { handleInstantRetreat(ctx, command as Extract), messageRespond: (command) => handleMessageRespond(ctx, command as Extract), + syncDiplomaticResponse: (command) => + handleSyncDiplomaticResponse( + ctx, + command as Extract + ), vacation: (command) => handleVacation(ctx, command as Extract), setMySetting: (command) => handleSetMySetting(ctx, command as Extract), @@ -3212,6 +3294,7 @@ export const createTurnDaemonCommandHandler = (options: { return null; } ctx.commandDb = executionContext?.db; + ctx.clockOperationAuthority = executionContext?.clockOperationAuthority; try { if (isActorBoundGeneralCommand(command)) { const rejected = await validateActorBoundGeneralCommand(ctx, command); @@ -3222,6 +3305,7 @@ export const createTurnDaemonCommandHandler = (options: { return await handler(command); } finally { ctx.commandDb = undefined; + ctx.clockOperationAuthority = undefined; } }, }; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 2a6caabc..8e0ae79b 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -26,7 +26,14 @@ import { normalizeScenarioEffect } from '@sammo-ts/logic'; import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { z } from 'zod'; -import { GameClock, asRecord, isRecord, type GameClockMode } from '@sammo-ts/common'; +import { + GameClock, + asRecord, + inferClockPhase, + isRecord, + parseGameClockPhase, + type GameClockMode, +} from '@sammo-ts/common'; import type { MapLoaderOptions } from '../scenario/mapLoader.js'; import { loadMapDefinitionByName } from '../scenario/mapLoader.js'; @@ -433,6 +440,14 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) worldState.clockWallAnchor !== null && worldState.lastTurnTick !== null; const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual'; + const clockPhase = hasPersistedClock + ? parseGameClockPhase(worldState.clockPhase) + : inferClockPhase(clockMode); + const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision'); + const deadlineGeneration = toSafeTick( + worldState.deadlineGeneration, + 'world_state.deadline_generation' + ); const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime; const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime; const bootstrapClock = new GameClock({ @@ -441,6 +456,8 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) mode: clockMode, wallAnchor: clockWallAnchor, turnSeconds: worldState.tickSeconds, + phase: clockPhase, + revision: clockRevision, }); const legacyLastTurnTick = bootstrapClock.dateToTick(legacyLastTurnTime); const gameClock = new GameClock({ @@ -452,6 +469,8 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) mode: clockMode, wallAnchor: clockWallAnchor, turnSeconds: worldState.tickSeconds, + phase: clockPhase, + revision: clockRevision, }); const ranksByGeneral = new Map(); @@ -519,6 +538,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) clockMode, clockWallAnchor: gameClock.wallAnchor, lastTurnTick, + clockPhase, + clockRevision, + deadlineGeneration, meta, }, snapshot: { diff --git a/app/game-engine/test/accountIconPersistence.integration.test.ts b/app/game-engine/test/accountIconPersistence.integration.test.ts index be3d9260..25eb7149 100644 --- a/app/game-engine/test/accountIconPersistence.integration.test.ts +++ b/app/game-engine/test/accountIconPersistence.integration.test.ts @@ -283,6 +283,7 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => { command: Extract, actorUserId = command.userId ): Promise => { + const clock = world.getGameClockState(); await db.inputEvent.create({ data: { requestId: command.requestId!, @@ -294,6 +295,12 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => { leaseUntil: new Date('2026-07-31T09:30:00.000Z'), attempts: 1, payload: command as GamePrisma.InputJsonValue, + acceptedGameTick: BigInt(clock.tick), + acceptedClockRevision: BigInt(clock.revision), + acceptedDeadlineGeneration: BigInt(clock.deadlineGeneration), + processingGameTick: BigInt(clock.tick), + processingClockRevision: BigInt(clock.revision), + processingDeadlineGeneration: BigInt(clock.deadlineGeneration), }, }); }; 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/authenticatedActorCommand.test.ts b/app/game-engine/test/authenticatedActorCommand.test.ts index d7a82780..6829ce53 100644 --- a/app/game-engine/test/authenticatedActorCommand.test.ts +++ b/app/game-engine/test/authenticatedActorCommand.test.ts @@ -46,6 +46,15 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [ officerLevel: 4, }, { type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] }, + { + type: 'syncDiplomaticResponse', + requestId: 'syncDiplomaticResponse', + userId, + generalId: 7, + messageId: 31, + nationIds: [1, 2], + cityIds: [1], + }, ]; const buildReadOnlyWorld = (ownerUserId: string) => { @@ -181,6 +190,66 @@ describe('authenticated actor-bound command registry and execution', () => { expect(mutation).not.toHaveBeenCalled(); }); + it('refreshes the daemon world from the committed diplomatic response before the next turn', async () => { + const updateNation = vi.fn(); + const applyDiplomacyPatch = vi.fn(); + const updateCity = vi.fn(); + const world = { + getGeneralById: vi.fn(() => ({ id: 7, userId: 'old-owner' })), + updateNation, + applyDiplomacyPatch, + updateCity, + } as unknown as InMemoryTurnWorld; + const db = { + inputEvent: { + findUnique: vi.fn(async () => ({ + actorUserId: 'old-owner', + target: 'ENGINE', + eventType: 'syncDiplomaticResponse', + })), + }, + messageAction: { findUnique: vi.fn(async () => ({ status: 'RESOLVED' })) }, + nation: { findMany: vi.fn(async () => [{ id: 1, meta: { policy: 'balanced' } }]) }, + diplomacy: { + findMany: vi.fn(async () => [ + { srcNationId: 1, destNationId: 2, stateCode: 7, term: 12, meta: { dead: 3 } }, + ]), + }, + city: { findMany: vi.fn(async () => [{ id: 4, frontState: 2 }]) }, + }; + const handler = createTurnDaemonCommandHandler({ world }); + + await expect( + handler.handle( + { + type: 'syncDiplomaticResponse', + requestId: 'syncDiplomaticResponse', + userId: 'old-owner', + generalId: 7, + messageId: 31, + nationIds: [1, 2], + cityIds: [4], + }, + { db: db as never } + ) + ).resolves.toEqual({ + type: 'syncDiplomaticResponse', + ok: true, + generalId: 7, + messageId: 31, + nations: 1, + diplomacy: 1, + cities: 1, + }); + expect(updateNation).toHaveBeenCalledWith(1, { meta: { policy: 'balanced' } }); + expect(applyDiplomacyPatch).toHaveBeenCalledWith({ + srcNationId: 1, + destNationId: 2, + patch: { state: 7, term: 12, dead: 3, meta: {} }, + }); + expect(updateCity).toHaveBeenCalledWith(4, { frontState: 2 }); + }); + it('preserves direct in-memory invocation when no command database is supplied', async () => { const updateGeneral = vi.fn(); const world = { diff --git a/app/game-engine/test/clockReconciliation.integration.test.ts b/app/game-engine/test/clockReconciliation.integration.test.ts new file mode 100644 index 00000000..f8518338 --- /dev/null +++ b/app/game-engine/test/clockReconciliation.integration.test.ts @@ -0,0 +1,501 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GameClock } from '@sammo-ts/common'; +import { + createGamePostgresConnector, + createRedisConnector, + GENERAL_ACCESS_PERSISTENCE_LOCK, + GamePrisma, + acquireGameSchemaAdvisoryXactLock, + type GamePrismaClient, + type RedisConnector, +} from '@sammo-ts/infra'; + +import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js'; +import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js'; + +const enabled = + process.env.CLOCK_RECONCILIATION_INTEGRATION === '1' && + Boolean(process.env.DATABASE_URL) && + Boolean(process.env.REDIS_URL); +const describeIntegration = enabled ? describe : describe.skip; + +describeIntegration('durable clock reconciliation', () => { + let db: GamePrismaClient; + let disconnect: (() => Promise) | undefined; + let redis: RedisConnector; + + const clean = async (): Promise => { + await redis.client.flushDb(); + await db.$transaction([ + db.clockProjectionOutbox.deleteMany(), + db.clockReconciliationParticipant.deleteMany(), + db.clockSuspension.deleteMany(), + db.inputEvent.deleteMany(), + db.vote.deleteMany(), + db.voteComment.deleteMany(), + db.votePoll.deleteMany(), + db.message.deleteMany(), + db.auctionBid.deleteMany(), + db.auction.deleteMany(), + db.npcSelectionToken.deleteMany(), + db.selectPoolEntry.deleteMany(), + db.general.deleteMany(), + db.turnDaemonLease.deleteMany(), + db.worldState.deleteMany(), + ]); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! }); + db = connector.prisma; + disconnect = connector.disconnect; + redis = createRedisConnector({ url: process.env.REDIS_URL! }); + await redis.connect(); + }); + + afterAll(async () => { + await clean(); + await redis.disconnect(); + await disconnect?.(); + }); + + beforeEach(async () => { + await clean(); + }); + + it('preserves every remaining deadline and occurrence across a 65m17.250s exact gap', async () => { + const baseTime = new Date('2026-01-01T00:00:00.000Z'); + const futureAnchor = new Date(Date.now() + 3_600_000); + const initialTick = 1_000_000; + const lastTurnTick = 900_000; + const clock = new GameClock({ + baseTime, + tick: initialTick, + mode: 'realtime', + wallAnchor: futureAnchor, + turnSeconds: 600, + phase: 'RUNNING', + 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; + const voteStartTick = initialTick - 200; + const voteEndTick = initialTick + 18_000_321; + const poolTick = initialTick + 2_000_111; + const npcValidTick = initialTick + 3_000_222; + const npcMoreTick = initialTick + 1_000_333; + + const world = await db.worldState.create({ + data: { + scenarioCode: 'clock-test', + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + clockBaseTime: baseTime, + clockTick: BigInt(initialTick), + clockMode: 'realtime', + clockWallAnchor: futureAnchor, + lastTurnTick: BigInt(lastTurnTick), + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 7n, + meta: { + lastTurnTime: clock.tickToDate(lastTurnTick).toISOString(), + starttime: clock.tickToDate(initialTick + 100).toISOString(), + }, + }, + }); + await db.general.createMany({ + data: generalTicks.map((turnTick, index) => ({ + id: index + 1, + name: `general-${index + 1}`, + turnTick: BigInt(turnTick), + 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({ + data: { + type: 'BUY_RICE', + hostGeneralId: 1, + status: 'FINALIZING', + openTick: BigInt(initialTick - 300), + closeTick: BigInt(auctionCloseTick), + closeAt: clock.tickToDate(auctionCloseTick), + }, + }); + await db.message.create({ + data: { + mailbox: 1, + type: 'private', + src: 1, + dest: 2, + time: clock.tickToDate(messageOccurrenceTick), + 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({ + data: { + title: 'clock vote', + options: ['yes', 'no'], + revealMode: 'AFTER_VOTE', + openerGeneralId: 1, + openerName: 'general-1', + startAt: clock.tickToDate(voteStartTick), + startTick: BigInt(voteStartTick), + endAt: clock.tickToDate(voteEndTick), + endTick: BigInt(voteEndTick), + }, + }); + await db.selectPoolEntry.create({ + data: { + uniqueName: 'clock-pool', + reservedUntil: clock.tickToDate(poolTick), + reservedUntilTick: BigInt(poolTick), + info: {}, + }, + }); + await db.npcSelectionToken.create({ + data: { + ownerUserId: 'clock-user', + validUntil: clock.tickToDate(npcValidTick), + validUntilTick: BigInt(npcValidTick), + pickMoreFrom: clock.tickToDate(npcMoreTick), + pickMoreFromTick: BigInt(npcMoreTick), + pickResult: [], + nonce: 1, + }, + }); + + const authority = { kind: 'OFFLINE' as const, profileName: 'clock-test', reason: 'integration fixture' }; + const suspended = await startClockSuspension({ + db, + suspensionId: 'clock-gap-65m17s250', + source: 'MAINTENANCE', + authority, + }); + expect(suspended.cutTick).toBe(initialTick); + expect((await db.worldState.findUniqueOrThrow({ where: { id: world.id } })).clockPhase).toBe('SUSPENDED'); + + const resumeWallAt = new Date(suspended.cutWallAt.getTime() + 65 * 60_000 + 17_250); + const reconciled = await reconcileClockSuspension({ + db, + suspensionId: suspended.suspensionId, + authority, + testResumeWallAt: resumeWallAt, + }); + expect(reconciled).toMatchObject({ + phase: 'RECONCILING', + sourceRevision: 1, + targetRevision: 2, + deadlineGeneration: 8, + gapTicks: 235_035_000, + shiftTicks: 235_035_000, + alignedTick: 236_035_000, + }); + + 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', + clockRevision: 2n, + deadlineGeneration: 8n, + clockTick: alignedTick, + lastTurnTick: BigInt(lastTurnTick + reconciled.shiftTicks), + }); + 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)); + expect(token.pickMoreFromTick! - alignedTick).toBe(BigInt(npcMoreTick - initialTick)); + expect(generals.map((general) => general.recentWarTick)).toEqual([ + BigInt(initialTick - 100), + BigInt(initialTick - 101), + ]); + expect(auction.openTick).toBe(BigInt(initialTick - 300)); + expect(message.timeTick).toBe(BigInt(messageOccurrenceTick)); + expect(vote.startTick).toBe(BigInt(voteStartTick)); + expect(ledger.status).toBe('RECONCILING'); + expect(outboxes).toHaveLength(1); + expect(outboxes[0]).toMatchObject({ status: 'PENDING', targetRevision: 2n }); + + const retried = await reconcileClockSuspension({ + db, + suspensionId: suspended.suspensionId, + authority, + testResumeWallAt: new Date(resumeWallAt.getTime() + 10_000), + }); + expect(retried).toEqual(reconciled); + expect(await db.clockProjectionOutbox.count()).toBe(1); + const keepParticipants = await db.clockReconciliationParticipant.findMany({ where: { policy: 'KEEP' } }); + expect(keepParticipants.every((participant) => participant.beforeChecksum === participant.afterChecksum)).toBe( + true + ); + + await redis.client.set('sammo:clock-test:clock:active-revision', '1'); + expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'clock-projection-success' })).toBe( + 'APPLIED' + ); + expect(await redis.client.get('sammo:clock-test:clock:active-revision')).toBe('2'); + expect(await redis.client.get('sammo:clock-test:clock:deadline-generation')).toBe('8'); + expect(await redis.client.get('sammo:clock-test:clock:phase')).toBe('RUNNING'); + expect(await redis.client.zRangeWithScores('sammo:clock-test:auction:timer', 0, -1)).toEqual([ + { value: String(auction.id), score: Number(auction.closeTick) }, + ]); + expect(await db.worldState.findUniqueOrThrow({ where: { id: world.id } })).toMatchObject({ + clockPhase: 'RUNNING', + clockRevision: 2n, + }); + expect(await db.clockProjectionOutbox.findFirstOrThrow()).toMatchObject({ status: 'APPLIED' }); + }); + + it('rejects a live offline fence and preserves a turn deadline across an exact 24-hour gap', async () => { + const baseTime = new Date('2026-02-01T00:00:00.000Z'); + const futureAnchor = new Date(Date.now() + 3_600_000); + const initialTick = 5 * 36_000_000; + const turnTick = initialTick + 17_000_007; + const clock = new GameClock({ + baseTime, + tick: initialTick, + mode: 'realtime', + wallAnchor: futureAnchor, + turnSeconds: 3_600, + phase: 'RUNNING', + }); + await db.worldState.create({ + data: { + scenarioCode: 'clock-day-test', + currentYear: 180, + currentMonth: 1, + tickSeconds: 3_600, + clockBaseTime: baseTime, + clockTick: BigInt(initialTick), + clockMode: 'realtime', + clockWallAnchor: futureAnchor, + lastTurnTick: BigInt(initialTick), + clockPhase: 'RUNNING', + clockRevision: 3n, + deadlineGeneration: 2n, + }, + }); + 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', + ownerId: 'other-daemon', + fencingEpoch: 9n, + leaseUntil: new Date(Date.now() + 60_000), + }, + }); + const authority = { + kind: 'OFFLINE' as const, + profileName: 'clock-day-test', + reason: '24-hour integration fixture', + }; + await expect( + startClockSuspension({ + db, + suspensionId: 'clock-gap-24h', + source: 'MAINTENANCE', + authority, + }) + ).rejects.toThrow('daemon lease to be offline'); + await db.turnDaemonLease.delete({ where: { profile: 'clock-day-test' } }); + const suspended = await startClockSuspension({ + db, + suspensionId: 'clock-gap-24h', + source: 'MAINTENANCE', + authority, + }); + const reconciled = await reconcileClockSuspension({ + db, + suspensionId: suspended.suspensionId, + authority, + testResumeWallAt: new Date(suspended.cutWallAt.getTime() + 24 * 60 * 60_000), + }); + + expect(reconciled).toMatchObject({ + sourceRevision: 3, + targetRevision: 4, + gapTicks: 24 * 36_000_000, + shiftTicks: 24 * 36_000_000, + alignedTick: initialTick + 24 * 36_000_000, + }); + 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 = { + get: redis.client.get.bind(redis.client), + eval: async (script: string, options: { keys: string[]; arguments: string[] }) => { + await redis.client.eval(script, options); + throw new Error('fixture crash after Redis commit'); + }, + }; + await expect( + applyNextClockProjection({ db, redis: redisThenCrash, workerId: 'clock-projection-crash' }) + ).rejects.toThrow('fixture crash after Redis commit'); + expect(await redis.client.get('sammo:clock-day-test:clock:active-revision')).toBe('4'); + expect(await db.worldState.findFirstOrThrow()).toMatchObject({ clockPhase: 'RECONCILING' }); + expect(await db.clockProjectionOutbox.findFirstOrThrow()).toMatchObject({ status: 'FAILED', attempts: 1 }); + + await db.clockProjectionOutbox.updateMany({ data: { availableAt: new Date(0) } }); + expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'clock-projection-restart' })).toBe( + 'RECOVERED' + ); + expect(await db.worldState.findFirstOrThrow()).toMatchObject({ clockPhase: 'RUNNING', clockRevision: 4n }); + expect(await db.clockProjectionOutbox.findFirstOrThrow()).toMatchObject({ status: 'APPLIED', attempts: 2 }); + }); + + it('uses DB wall time despite host drift and does not deadlock with a general-access writer', async () => { + const [dbWall] = await db.$queryRaw>(GamePrisma.sql` + SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS now + `); + const baseTime = new Date('2026-03-01T00:00:00.000Z'); + const clock = new GameClock({ + baseTime, + tick: 42, + mode: 'realtime', + wallAnchor: dbWall!.now, + turnSeconds: 600, + phase: 'RUNNING', + revision: 1, + }); + const world = await db.worldState.create({ + data: { + scenarioCode: 'clock-drift-deadlock-test', + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + clockBaseTime: baseTime, + clockTick: 42n, + clockMode: 'realtime', + clockWallAnchor: dbWall!.now, + lastTurnTick: 42n, + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, + }, + }); + await db.general.create({ + data: { id: 1, name: 'lock-general', turnTick: 100n, turnTime: clock.tickToDate(100) }, + }); + + let releaseWriter!: () => void; + let signalWriterLocked!: () => void; + const writerLocked = new Promise((resolve) => { + signalWriterLocked = resolve; + }); + const writerRelease = new Promise((resolve) => { + releaseWriter = resolve; + }); + const writer = db.$transaction(async (transaction) => { + await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK); + signalWriterLocked(); + await writerRelease; + await transaction.$queryRaw(GamePrisma.sql` + SELECT id FROM world_state WHERE id = ${world.id} FOR UPDATE + `); + }); + await writerLocked; + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(dbWall!.now.getTime() + 12 * 60 * 60_000); + try { + const suspensionPromise = startClockSuspension({ + db, + suspensionId: 'clock-host-drift-deadlock', + source: 'MAINTENANCE', + authority: { kind: 'OFFLINE', profileName: 'clock-drift-deadlock-test', reason: 'fixture' }, + }); + releaseWriter(); + const suspension = await Promise.race([ + Promise.all([writer, suspensionPromise]).then(([, result]) => result), + new Promise((_, reject) => + setTimeout(() => reject(new Error('general-access/clock-operation deadlock')), 5_000) + ), + ]); + expect(Math.abs(suspension.cutWallAt.getTime() - dbWall!.now.getTime())).toBeLessThan(5_000); + expect(suspension.cutTick).toBeGreaterThanOrEqual(42); + expect(suspension.cutTick).toBeLessThan(42 + 60 * 60_000); + } finally { + dateNow.mockRestore(); + releaseWriter(); + } + }); +}); diff --git a/app/game-engine/test/clockReconciliationRetry.test.ts b/app/game-engine/test/clockReconciliationRetry.test.ts new file mode 100644 index 00000000..971b6f25 --- /dev/null +++ b/app/game-engine/test/clockReconciliationRetry.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrismaClient } from '@sammo-ts/infra'; +import { + reconcileClockSuspension, + startClockSuspension, + type ClockReconciliationResult, + type ClockSuspensionResult, +} from '../src/turn/clockReconciliation.js'; + +const authority = { kind: 'OFFLINE' as const, profileName: 'retry-test', reason: 'isolated unit test' }; + +describe('serializable clock operation retries', () => { + it('retries a PostgreSQL serialization conflict while starting suspension', async () => { + const result: ClockSuspensionResult = { + suspensionId: 'retry-start', + phase: 'SUSPENDED', + sourceRevision: 7, + targetRevision: 8, + cutTick: 123, + cutWallAt: new Date('2026-09-03T10:00:00.000Z'), + }; + const transaction = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('could not serialize access'), { code: 'P2034' })) + .mockResolvedValueOnce(result); + const db = { $transaction: transaction } as unknown as GamePrismaClient; + + await expect( + startClockSuspension({ db, suspensionId: result.suspensionId, source: 'MAINTENANCE', authority }) + ).resolves.toEqual(result); + expect(transaction).toHaveBeenCalledTimes(2); + }); + + it('retries a raw 40001 conflict while reconciling suspension', async () => { + const result: ClockReconciliationResult = { + suspensionId: 'retry-resume', + phase: 'RECONCILING', + sourceRevision: 7, + targetRevision: 8, + deadlineGeneration: 8, + gapTicks: 100, + catchUpTicks: 0, + shiftTicks: 100, + alignedTick: 223, + resumeWallAt: new Date('2026-09-03T10:10:00.000Z'), + }; + const transaction = vi + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('SQLSTATE 40001'), { code: 'P2010', meta: { code: '40001' } }) + ) + .mockResolvedValueOnce(result); + const db = { $transaction: transaction } as unknown as GamePrismaClient; + + await expect(reconcileClockSuspension({ db, suspensionId: result.suspensionId, authority })).resolves.toEqual( + result + ); + expect(transaction).toHaveBeenCalledTimes(2); + }); + + it('does not retry a non-serialization failure', async () => { + const transaction = vi.fn().mockRejectedValue(new Error('authority denied')); + const db = { $transaction: transaction } as unknown as GamePrismaClient; + + await expect( + startClockSuspension({ db, suspensionId: 'no-retry', source: 'MAINTENANCE', authority }) + ).rejects.toThrow('authority denied'); + expect(transaction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index 0ca4a18a..a5bf9f0c 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { TurnDaemonCommand } from '@sammo-ts/common'; import { createGamePostgresConnector } from '@sammo-ts/infra'; @@ -15,6 +15,55 @@ integration('database command queue', () => { let close: (() => Promise) | undefined; let db: GamePrismaClient; + const cleanupFixtures = async (): Promise => { + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'integration:engine:' } } }); + await db.clockProjectionOutbox.deleteMany({ + where: { + suspensionId: { + in: [ + 'integration-queue-revision-8-9', + 'integration-maintenance-suspension', + 'integration-unification-wait', + ], + }, + }, + }); + await db.clockSuspension.deleteMany({ + where: { + id: { + in: [ + 'integration-queue-revision-8-9', + 'integration-maintenance-suspension', + 'integration-unification-wait', + ], + }, + }, + }); + await db.message.deleteMany({ where: { mailbox: 991_199 } }); + await db.worldState.deleteMany({ + 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, + }, + }); + }; + beforeAll(async () => { const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); @@ -25,10 +74,13 @@ integration('database command queue', () => { }); }); + beforeEach(async () => { + await cleanupFixtures(); + await createClockFixture(); + }); + afterAll(async () => { - await db.inputEvent.deleteMany({ - where: { requestId: { startsWith: 'integration:engine:' } }, - }); + await cleanupFixtures(); await close?.(); }); @@ -49,7 +101,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 } }); @@ -104,7 +165,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', @@ -132,7 +202,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({ @@ -206,7 +283,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', @@ -229,4 +312,504 @@ integration('database command queue', () => { expect(handle).toHaveBeenCalledOnce(); expect(mutation).not.toHaveBeenCalled(); }); + + it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => { + const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } }); + const world = existingWorld + ? await db.worldState.update({ + where: { id: existingWorld.id }, + data: { clockPhase: 'SUSPENDED', clockRevision: 9n, deadlineGeneration: 4n, clockTick: 123n }, + }) + : await db.worldState.create({ + data: { + scenarioCode: 'queue-clock-test', + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + clockPhase: 'SUSPENDED', + clockRevision: 9n, + deadlineGeneration: 4n, + clockTick: 123n, + }, + }); + const gameplayId = 'integration:engine:clock-gated-gameplay'; + const statusId = 'integration:engine:clock-gated-status'; + const staleId = 'integration:engine:clock-gated-stale'; + await db.inputEvent.createMany({ + data: [ + { + requestId: gameplayId, + target: 'ENGINE', + eventType: 'vacation', + actorUserId: 'user-7', + acceptedGameTick: 100n, + acceptedClockRevision: 9n, + acceptedDeadlineGeneration: 4n, + payload: { type: 'vacation', requestId: gameplayId, userId: 'user-7', generalId: 7 }, + }, + { + requestId: statusId, + target: 'ENGINE', + eventType: 'getStatus', + acceptedGameTick: 100n, + acceptedClockRevision: 9n, + acceptedDeadlineGeneration: 4n, + payload: { type: 'getStatus', requestId: statusId }, + }, + { + requestId: staleId, + target: 'ENGINE', + eventType: 'vacation', + actorUserId: 'user-8', + acceptedGameTick: 90n, + acceptedClockRevision: 8n, + acceptedDeadlineGeneration: 3n, + payload: { type: 'vacation', requestId: staleId, userId: 'user-8', generalId: 8 }, + }, + ], + }); + const queue = new DatabaseTurnDaemonCommandQueue(db); + + 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, + }); + 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, + processingGameTick: 100, + requestedAtWall: expect.any(Date), + }, + ]); + expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({ + status: 'PROCESSING', + processingGameTick: 100n, + processingClockRevision: 9n, + processingDeadlineGeneration: 4n, + }); + expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({ + status: 'PENDING', + processingClockRevision: null, + }); + await db.clockSuspension.deleteMany({ where: { id: 'integration-queue-revision-8-9' } }); + await db.clockSuspension.create({ + data: { + id: 'integration-queue-revision-8-9', + worldStateId: world.id, + source: 'MAINTENANCE', + policy: 'EXACT', + status: 'APPLIED', + sourceRevision: 8n, + targetRevision: 9n, + cutTick: 90n, + cutWallAt: new Date(), + resumeWallAt: new Date(), + rateTicksPerSecond: 60_000, + gapTicks: 33n, + shiftTicks: 33n, + alignedTick: 123n, + }, + }); + expect(await queue.drain()).toEqual([ + { + type: 'vacation', + requestId: staleId, + userId: 'user-8', + generalId: 8, + processingGameTick: 123, + requestedAtWall: expect.any(Date), + }, + ]); + expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({ + status: 'PROCESSING', + acceptedGameTick: 90n, + acceptedClockRevision: 8n, + processingGameTick: 123n, + processingClockRevision: 9n, + processingDeadlineGeneration: 4n, + }); + }); + + 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 fenced immediate user mutations during a maintenance suspension', async () => { + const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + await db.worldState.update({ + where: { id: world.id }, + data: { clockPhase: 'SUSPENDED', clockRevision: 23n, deadlineGeneration: 9n, clockTick: 777n }, + }); + await db.clockSuspension.create({ + data: { + id: 'integration-maintenance-suspension', + worldStateId: world.id, + source: 'MAINTENANCE', + policy: 'EXACT', + status: 'SUSPENDED', + sourceRevision: 23n, + targetRevision: 24n, + cutTick: 777n, + cutWallAt: new Date(), + rateTicksPerSecond: 60_000, + }, + }); + const commands: TurnDaemonCommand[] = [ + { + type: 'inheritanceAction', + requestId: 'integration:engine:suspended-inheritance', + userId: 'user-7', + input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 }, + }, + { + type: 'dropItem', + requestId: 'integration:engine:suspended-drop-item', + userId: 'user-7', + generalId: 7, + itemType: 'weapon', + }, + { + type: 'changePermission', + requestId: 'integration:engine:suspended-permission', + userId: 'user-7', + generalId: 7, + isAmbassador: true, + targetGeneralIds: [8], + }, + { + type: 'appoint', + requestId: 'integration:engine:suspended-appoint', + userId: 'user-7', + generalId: 7, + destGeneralId: 8, + destCityId: 1, + officerLevel: 2, + }, + { + type: 'setNationSetting', + requestId: 'integration:engine:suspended-nation-setting', + userId: 'user-7', + generalId: 7, + nationId: 1, + mutation: { kind: 'rate', amount: 20 }, + }, + { + type: 'setNpcPolicy', + requestId: 'integration:engine:suspended-npc-policy', + userId: 'user-7', + generalId: 7, + nationId: 1, + expectedUpdatedAt: null, + mutation: { kind: 'nationPriority', priority: ['develop'] }, + }, + { + type: 'shiftSchedule', + requestId: 'integration:engine:suspended-shift-schedule', + actionId: '00000000-0000-4000-8000-000000000023', + deltaMinutes: -15, + }, + ]; + await db.inputEvent.createMany({ + data: commands.map((command) => ({ + requestId: command.requestId!, + target: 'ENGINE' as const, + eventType: command.type, + actorUserId: 'userId' in command ? command.userId : null, + payload: command as GamePrisma.InputJsonValue, + })), + }); + const blockedRequestId = 'integration:engine:suspended-vacation-still-gated'; + await db.inputEvent.create({ + data: { + requestId: blockedRequestId, + target: 'ENGINE', + eventType: 'vacation', + actorUserId: 'user-7', + payload: { + type: 'vacation', + requestId: blockedRequestId, + userId: 'user-7', + generalId: 7, + }, + }, + }); + + const claimed = await new DatabaseTurnDaemonCommandQueue(db).drain(); + expect(claimed.map(({ type }) => type)).toEqual(commands.map(({ type }) => type)); + for (const command of commands) { + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId: command.requestId! } }) + ).resolves.toMatchObject({ + status: 'PROCESSING', + processingGameTick: 777n, + processingClockRevision: 23n, + processingDeadlineGeneration: 9n, + }); + } + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId: blockedRequestId } }) + ).resolves.toMatchObject({ + status: 'PENDING', + processingClockRevision: null, + }); + }); + + 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 + ? await db.worldState.update({ + where: { id: existingWorld.id }, + data: { clockPhase: 'SUSPENDED', clockRevision: 31n, deadlineGeneration: 7n, clockTick: 900n }, + }) + : await db.worldState.create({ + data: { + scenarioCode: 'queue-unification-clock-test', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + clockPhase: 'SUSPENDED', + clockRevision: 31n, + deadlineGeneration: 7n, + clockTick: 900n, + }, + }); + const message = await db.message.create({ + data: { + mailbox: 991_199, + type: 'private', + src: 0, + dest: 991_199, + time: new Date(), + validUntil: new Date('9999-12-31T00:00:00.000Z'), + 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', + worldStateId: world.id, + source: 'UNIFICATION_WAIT', + policy: 'EXACT', + status: 'SUSPENDED', + sourceRevision: 31n, + targetRevision: 32n, + cutTick: 900n, + cutWallAt: new Date(), + rateTicksPerSecond: 60_000, + }, + }); + const messageRequestId = 'integration:engine:unification-message'; + const scoutRequestId = 'integration:engine:suspended-scout-response'; + const gameplayRequestId = 'integration:engine:unification-gameplay'; + await db.inputEvent.createMany({ + data: [ + { + requestId: messageRequestId, + target: 'ENGINE', + eventType: 'messageRespond', + actorUserId: 'user-991199', + acceptedGameTick: 900n, + acceptedClockRevision: 31n, + acceptedDeadlineGeneration: 7n, + payload: { + type: 'messageRespond', + requestId: messageRequestId, + userId: 'user-991199', + generalId: 991_199, + messageId: message.id, + 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', + eventType: 'vacation', + actorUserId: 'user-991199', + acceptedGameTick: 900n, + acceptedClockRevision: 31n, + acceptedDeadlineGeneration: 7n, + payload: { + type: 'vacation', + requestId: gameplayRequestId, + userId: 'user-991199', + generalId: 991_199, + }, + }, + ], + }); + + const queue = new DatabaseTurnDaemonCommandQueue(db); + await expect(queue.drain()).resolves.toEqual([ + { + type: 'messageRespond', + requestId: messageRequestId, + userId: 'user-991199', + 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/gatewayRuntimeAction.integration.test.ts b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts index aefa199d..363dc471 100644 --- a/app/game-engine/test/gatewayRuntimeAction.integration.test.ts +++ b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra'; import { createGatewayAdminActionConsumer } from '../src/turn/gatewayAdminActions.js'; +import { createGatewayProfileGate } from '../src/turn/gatewayProfileGate.js'; const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); @@ -109,4 +110,35 @@ integration('gateway runtime action consumer', () => { expect(handler).toHaveBeenCalledTimes(2); expect(onActionApplied).toHaveBeenCalledTimes(1); }); + + it('does not overwrite a terminal operator status while reporting a daemon error', async () => { + const gate = await createGatewayProfileGate({ + databaseUrl: databaseUrl!, + gatewayDatabaseUrl: databaseUrl!, + profileName, + }); + try { + await db.gatewayProfile.update({ + where: { profileName }, + data: { status: 'RUNNING', lastError: null }, + }); + await gate.markPaused(new Error('running failure')); + expect(await db.gatewayProfile.findUniqueOrThrow({ where: { profileName } })).toMatchObject({ + status: 'PAUSED', + lastError: 'running failure', + }); + + await db.gatewayProfile.update({ + where: { profileName }, + data: { status: 'STOPPED', lastError: null }, + }); + await gate.markPaused(new Error('late shutdown failure')); + expect(await db.gatewayProfile.findUniqueOrThrow({ where: { profileName } })).toMatchObject({ + status: 'STOPPED', + lastError: null, + }); + } finally { + await gate.close(); + } + }); }); diff --git a/app/game-engine/test/inheritanceActionPersistence.integration.test.ts b/app/game-engine/test/inheritanceActionPersistence.integration.test.ts index 24d47fad..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] } } }); @@ -263,6 +265,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => { const createInputEvent = async ( command: Extract ): Promise => { + const clock = world.getGameClockState(); await db.inputEvent.create({ data: { requestId: command.requestId!, @@ -274,10 +277,22 @@ integration('inheritance action PostgreSQL atomic persistence', () => { leaseUntil: new Date('2026-08-24T01:00:00.000Z'), attempts: 1, payload: command as GamePrisma.InputJsonValue, + acceptedGameTick: BigInt(clock.tick), + acceptedClockRevision: BigInt(clock.revision), + acceptedDeadlineGeneration: BigInt(clock.deadlineGeneration), + processingGameTick: BigInt(clock.tick), + processingClockRevision: BigInt(clock.revision), + processingDeadlineGeneration: BigInt(clock.deadlineGeneration), }, }); }; - 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' } }, @@ -292,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', { @@ -308,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({ @@ -317,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); @@ -328,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 }, @@ -351,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', @@ -365,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/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index 657e2187..bc0d5b15 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -126,6 +126,7 @@ const buildHarness = (options?: { currentMonth: 1, tickSeconds: 600, lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + clockPhase: 'RUNNING', meta: { hiddenSeed: 'raise-invader-fixture', serverId: 'fixture-server', @@ -410,6 +411,7 @@ describe('invader monthly actions', () => { await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 }); + expect(world.getState().clockPhase).toBe('COMPLETED'); expect(world.listEvents()).toHaveLength(0); expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([ '【이벤트】이민족을 모두 소탕했습니다!', @@ -449,6 +451,7 @@ describe('invader monthly actions', () => { await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 }); + expect(world.getState().clockPhase).toBe('COMPLETED'); expect(world.listEvents()).toHaveLength(0); expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([ '【이벤트】중원은 이민족에 의해 혼란에 빠졌습니다.', diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index 1afbed48..27534cdb 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -338,6 +338,12 @@ integration('RaiseInvader database persistence', () => { currentYear: 199, currentMonth: 12, tickSeconds: 600, + clockBaseTime: new Date('0200-01-01T00:00:00.000Z'), + clockTick: 0n, + clockMode: 'realtime', + clockWallAnchor: new Date('0200-01-01T00:00:00.000Z'), + lastTurnTick: 0n, + clockPhase: 'RUNNING', config: {}, meta: { hiddenSeed: serverId, @@ -354,6 +360,14 @@ integration('RaiseInvader database persistence', () => { currentMonth: 12, tickSeconds: 600, lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + clockBaseTime: new Date('0200-01-01T00:00:00.000Z'), + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: new Date('0200-01-01T00:00:00.000Z'), + lastTurnTick: 0, + clockPhase: 'RUNNING', + clockRevision: 1, + deadlineGeneration: 1, meta: { hiddenSeed: serverId, lastGeneralId: firstCreatedGeneralId - 1, @@ -610,6 +624,7 @@ integration('RaiseInvader database persistence', () => { leasedNationKeys: [], }); expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ + clockPhase: 'COMPLETED', meta: expect.objectContaining({ isunited: 3, isUnited: 3, refreshLimit: 300 }), }); expect( diff --git a/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts b/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts index 6640a4f3..0b860f1e 100644 --- a/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts +++ b/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts @@ -99,9 +99,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { afterAll(async () => { await hooks?.close(); if (db) { - await db.$executeRawUnsafe( - `ALTER TABLE read_model_outbox DROP CONSTRAINT IF EXISTS ${rollbackConstraint}` - ); + await db.$executeRawUnsafe(`ALTER TABLE read_model_outbox DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`); await db.inputEvent.deleteMany({ where: { requestId } }); await db.logEntry.deleteMany({ where: { text: { startsWith: '[read-model-journal]' } } }); await db.worldState.deleteMany({ where: { id: worldId } }); @@ -187,6 +185,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { currentMonth: 2, }); + const commandClock = world.getGameClockState(); await db.inputEvent.create({ data: { requestId, @@ -194,6 +193,12 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { eventType: 'shiftSchedule', status: 'PROCESSING', payload: {}, + acceptedGameTick: BigInt(commandClock.tick), + acceptedClockRevision: BigInt(commandClock.revision), + acceptedDeadlineGeneration: BigInt(commandClock.deadlineGeneration), + processingGameTick: BigInt(commandClock.tick), + processingClockRevision: BigInt(commandClock.revision), + processingDeadlineGeneration: BigInt(commandClock.deadlineGeneration), }, }); const directResult: TurnDaemonCommandResult = { @@ -250,9 +255,9 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { { domain: 'records.history', entityId: 0, revision: 1n }, ]); await expect(db.readModelOutbox.count()).resolves.toBe(3); - await expect(db.logEntry.count({ where: { text: { startsWith: '[read-model-journal] direct' } } })).resolves.toBe( - 3 - ); + await expect( + db.logEntry.count({ where: { text: { startsWith: '[read-model-journal] direct' } } }) + ).resolves.toBe(3); world.updateWorldMeta({ queueProbe: 1 }); await hooks.hooks.flushChanges?.(turnRunResult(world)); diff --git a/app/game-engine/test/runtimeClockAuthoritySync.test.ts b/app/game-engine/test/runtimeClockAuthoritySync.test.ts new file mode 100644 index 00000000..b7eb9122 --- /dev/null +++ b/app/game-engine/test/runtimeClockAuthoritySync.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrisma } from '@sammo-ts/infra'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { synchronizeRuntimeClockAuthorityUnderHeldLock } from '../src/turn/runtimeClockAuthoritySync.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const baseTime = new Date('2026-09-03T10:00:00.000Z'); + +const buildWorld = (phase: 'RUNNING' | 'SUSPENDED' = 'RUNNING'): InMemoryTurnWorld => { + const general = { + id: 1, + name: 'clock-sync-general', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('2026-09-03T10:10:00.000Z'), + turnTick: 36_000_000, + role: { items: { horse: null, weapon: null, book: null, item: null } }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + officerLevel: 5, + experience: 0, + dedication: 0, + injury: 0, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + } as TurnGeneral; + const state: TurnWorldState = { + id: 1, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: baseTime, + clockBaseTime: baseTime, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: baseTime, + lastTurnTick: 0, + clockPhase: phase, + clockRevision: 3, + deadlineGeneration: 5, + meta: { lastTurnTime: baseTime.toISOString() }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'clock-sync', + name: 'clock-sync', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + }; + return new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); +}; + +const buildDb = (worldState: Record, ledgers: unknown[] = []): GamePrisma.TransactionClient => + ({ + worldState: { findFirst: vi.fn().mockResolvedValue(worldState) }, + clockSuspension: { findMany: vi.fn().mockResolvedValue(ledgers) }, + }) as unknown as GamePrisma.TransactionClient; + +describe('runtime clock authority synchronization', () => { + it('adopts a maintenance suspension cut without advancing the game schedule', async () => { + const world = buildWorld('RUNNING'); + const cutWallAt = new Date('2026-09-03T10:02:00.000Z'); + const beforeTurnTick = world.getGeneralById(1)!.turnTick; + const db = buildDb({ + id: 1, + clockBaseTime: baseTime, + clockTick: 7_200_000n, + clockMode: 'realtime', + clockWallAnchor: cutWallAt, + lastTurnTick: 0n, + clockPhase: 'SUSPENDED', + clockRevision: 3n, + deadlineGeneration: 5n, + }); + + await expect(synchronizeRuntimeClockAuthorityUnderHeldLock(db, world)).resolves.toBe(true); + + expect(world.getGameClockState()).toMatchObject({ + phase: 'SUSPENDED', + tick: 7_200_000, + revision: 3, + deadlineGeneration: 5, + wallAnchor: cutWallAt, + }); + expect(world.getGeneralById(1)!.turnTick).toBe(beforeTurnTick); + }); + + it('replays the durable reconciliation shift before returning to RUNNING', async () => { + const world = buildWorld('SUSPENDED'); + const resumeWallAt = new Date('2026-09-03T11:00:00.000Z'); + const shiftTicks = 5_000; + const beforeTurnTick = world.getGeneralById(1)!.turnTick!; + const db = buildDb( + { + id: 1, + clockBaseTime: baseTime, + clockTick: 5_000n, + clockMode: 'realtime', + clockWallAnchor: resumeWallAt, + lastTurnTick: 5_000n, + clockPhase: 'RUNNING', + clockRevision: 4n, + deadlineGeneration: 6n, + }, + [ + { + id: 'maintenance-revision-3', + sourceRevision: 3n, + targetRevision: 4n, + shiftTicks: BigInt(shiftTicks), + alignedTick: 5_000n, + resumeWallAt, + }, + ] + ); + + await expect(synchronizeRuntimeClockAuthorityUnderHeldLock(db, world)).resolves.toBe(true); + + expect(world.getGameClockState()).toMatchObject({ + phase: 'RUNNING', + tick: 5_000, + lastTurnTick: 5_000, + revision: 4, + deadlineGeneration: 6, + wallAnchor: resumeWallAt, + }); + expect(world.getGeneralById(1)!.turnTick).toBe(beforeTurnTick + shiftTicks); + }); + + it('rejects a revision jump when the durable ledger chain is incomplete', async () => { + const world = buildWorld('SUSPENDED'); + const db = buildDb({ + id: 1, + clockBaseTime: baseTime, + clockTick: 1n, + clockMode: 'realtime', + clockWallAnchor: baseTime, + lastTurnTick: 1n, + clockPhase: 'RUNNING', + clockRevision: 4n, + deadlineGeneration: 6n, + }); + + await expect(synchronizeRuntimeClockAuthorityUnderHeldLock(db, world)).rejects.toThrow( + /ledger chain ended at 3\/5/ + ); + }); +}); diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index bad95498..097cb22c 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -183,17 +183,43 @@ describe('runtime clock shift', () => { clockMode: 'realtime', clockWallAnchor: openAt, lastTurnTick: 0, + clockPhase: 'PREOPEN', }); const preopenAt = new Date('2026-09-02T23:03:00.000Z'); expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime()); expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase); expect(world.getRunnableGameNow(openAt)).toEqual(gameBase); + expect(world.promotePreopenAtOpening(openAt)).toBe(true); expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual( new Date(gameBase.getTime() + 60_000) ); }); + it('promotes PREOPEN only at an opening tick-zero anchor', () => { + const openAt = new Date('2026-09-02T23:30:00.000Z'); + const world = buildWorld({ + clockBaseTime: new Date('2026-07-30T10:00:00.000Z'), + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: openAt, + lastTurnTick: 0, + clockPhase: 'PREOPEN', + }); + + expect(world.promotePreopenAtOpening(new Date(openAt.getTime() - 1))).toBe(false); + expect(world.promotePreopenAtOpening(openAt)).toBe(true); + expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 }); + }); + + it('rejects gameplay commits while the durable clock is suspended', async () => { + const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' }); + + expect(() => world.advanceGameClockTo(new Date(), new Date())).toThrow(/SUSPENDED/); + expect(() => world.executeGeneralTurn(world.listGenerals()[0]!)).toThrow(/SUSPENDED/); + await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/); + }); + it.each([ [5, 6], [10, 3], @@ -422,6 +448,21 @@ describe('runtime clock shift projection', () => { return {}; }); const db = { + $transaction: async (operation: (transaction: GamePrismaClient) => Promise) => operation(db), + $executeRaw: vi.fn(async () => 1), + $queryRaw: vi.fn(async () => [{ wallNow: new Date('2026-07-30T10:00:00.000Z') }]), + worldState: { + findFirst: vi.fn(async () => ({ + clockBaseTime: new Date('2026-07-30T10:00:00.000Z'), + clockTick: 0n, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-07-30T10:00:00.000Z'), + tickSeconds: 600, + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, + })), + }, inputEvent: { create: inputEventCreate, findUniqueOrThrow: vi.fn(async () => @@ -541,6 +582,21 @@ describe('runtime game settings projection', () => { let eventStatus: 'PENDING' | 'SUCCEEDED' = 'PENDING'; let created = false; const db = { + $transaction: async (operation: (transaction: GamePrismaClient) => Promise) => operation(db), + $executeRaw: vi.fn(async () => 1), + $queryRaw: vi.fn(async () => [{ wallNow: new Date('2026-07-30T10:00:00.000Z') }]), + worldState: { + findFirst: vi.fn(async () => ({ + clockBaseTime: new Date('2026-07-30T10:00:00.000Z'), + clockTick: 0n, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-07-30T10:00:00.000Z'), + tickSeconds: 600, + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, + })), + }, inputEvent: { create: vi.fn(async () => { if (created) throw { code: 'P2002' }; diff --git a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts index f7c49205..6bdd5f90 100644 --- a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts +++ b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; @@ -77,38 +77,32 @@ integration('runtime clock shift persistence', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; + const cleanupFixtures = async (): Promise => { + await db.inputEvent.deleteMany({ where: { requestId: { in: [requestId, runtimeSettingsRequestId] } } }); + await db.votePoll.deleteMany({ where: { openerGeneralId: generalIds[2] } }); + await db.message.deleteMany({ where: { mailbox: generalIds[2] } }); + await db.logEntry.deleteMany({ where: { text: runtimeSettingsLogText } }); + await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); + await db.selectPoolEntry.deleteMany({ where: { uniqueName: backlogPoolUniqueName } }); + await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); + await db.worldState.deleteMany({ + where: { + scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings', 'realtime-backlog-rebase'] }, + }, + }); + }; + beforeAll(async () => { const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); - await db.inputEvent.deleteMany({ where: { requestId: { in: [requestId, runtimeSettingsRequestId] } } }); - await db.votePoll.deleteMany({ where: { openerGeneralId: generalIds[2] } }); - await db.message.deleteMany({ where: { mailbox: generalIds[2] } }); - await db.logEntry.deleteMany({ where: { text: runtimeSettingsLogText } }); - await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); - await db.selectPoolEntry.deleteMany({ where: { uniqueName: backlogPoolUniqueName } }); - await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); - await db.worldState.deleteMany({ - where: { - scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings', 'realtime-backlog-rebase'] }, - }, - }); }); + beforeEach(cleanupFixtures); + afterAll(async () => { - await db.inputEvent.deleteMany({ where: { requestId: { in: [requestId, runtimeSettingsRequestId] } } }); - await db.votePoll.deleteMany({ where: { openerGeneralId: generalIds[2] } }); - await db.message.deleteMany({ where: { mailbox: generalIds[2] } }); - await db.logEntry.deleteMany({ where: { text: runtimeSettingsLogText } }); - await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); - await db.selectPoolEntry.deleteMany({ where: { uniqueName: backlogPoolUniqueName } }); - await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); - await db.worldState.deleteMany({ - where: { - scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings', 'realtime-backlog-rebase'] }, - }, - }); + await cleanupFixtures(); await closeDb?.(); }); @@ -469,6 +463,7 @@ integration('runtime clock shift persistence', () => { clockBaseTime: base, clockTick: 0, clockMode: 'manual', + clockPhase: 'MANUAL', clockWallAnchor: base, lastTurnTick: 0, config: { turnTermMinutes: 10, blockGeneralCreate: 0 }, 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/scenarioSeederClock.test.ts b/app/game-engine/test/scenarioSeederClock.test.ts index 528cab1e..50717b38 100644 --- a/app/game-engine/test/scenarioSeederClock.test.ts +++ b/app/game-engine/test/scenarioSeederClock.test.ts @@ -1,6 +1,6 @@ import { GameClock } from '@sammo-ts/common'; import { describe, expect, test } from 'vitest'; -import { calculateInitialTurnTick } from '../src/scenario/scenarioSeeder.js'; +import { calculateInitialTurnTick, resolveInitialClockPhase } from '../src/scenario/scenarioSeeder.js'; describe('scenario seeder general turn tick', () => { test('preserves Ref-compatible sub-millisecond RNG precision', () => { @@ -17,4 +17,31 @@ describe('scenario seeder general turn tick', () => { expect(calculateInitialTurnTick(clock, baseTick, 235_265_319)).toBe(baseTick + 14_115_919); expect(clock.dateToTick(new Date(now.getTime() + 235_265))).toBe(baseTick + 14_115_900); }); + + test('keeps formal opening at tick zero while PREOPEN projects signed ticks', () => { + const seededAt = new Date('2030-01-01T01:00:00.000Z'); + const openAt = new Date('2030-01-01T02:00:00.000Z'); + const phase = resolveInitialClockPhase('realtime', seededAt, openAt); + const clock = new GameClock({ + baseTime: new Date('0190-01-01T00:00:00.000Z'), + tick: 0, + mode: 'realtime', + wallAnchor: openAt, + turnSeconds: 600, + phase, + }); + + expect(phase).toBe('PREOPEN'); + expect(clock.nowTick(seededAt)).toBe(-6 * 36_000_000); + expect(clock.nowTick(openAt)).toBe(0); + expect(calculateInitialTurnTick(clock, 0, 0)).toBe(0); + expect(calculateInitialTurnTick(clock, 0, 235_265_319)).toBeGreaterThanOrEqual(0); + }); + + test('uses explicit MANUAL and immediate RUNNING phases', () => { + const now = new Date('2030-01-01T01:00:00.000Z'); + + expect(resolveInitialClockPhase('manual', now, new Date('2030-01-02T01:00:00.000Z'))).toBe('MANUAL'); + expect(resolveInitialClockPhase('realtime', now, now)).toBe('RUNNING'); + }); }); 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/turnDaemonLease.integration.test.ts b/app/game-engine/test/turnDaemonLease.integration.test.ts index 540620cc..e6232d1e 100644 --- a/app/game-engine/test/turnDaemonLease.integration.test.ts +++ b/app/game-engine/test/turnDaemonLease.integration.test.ts @@ -207,6 +207,57 @@ integration('database turn daemon lease and fencing', () => { expect(row.ownerId).toBe(firstToken ? 'owner-a' : 'owner-b'); }); + it('persists and evaluates lease wall time as UTC under a non-UTC database session', async () => { + const profile = `${profilePrefix}utc-wall`; + const zonedUrl = new URL(databaseUrl!); + const schema = zonedUrl.searchParams.get('schema') ?? 'public'; + zonedUrl.searchParams.delete('schema'); + zonedUrl.searchParams.set('options', `-c search_path=${schema} -c TimeZone=Asia/Seoul`); + const zonedConnector = createGamePostgresConnector({ url: zonedUrl.toString() }); + await zonedConnector.connect(); + const lease = await DatabaseTurnDaemonLease.connect(zonedUrl.toString(), { + profile, + ownerId: 'utc-owner', + leaseDurationMs: 60_000, + heartbeat: false, + }); + leases.push(lease); + try { + await expect(lease.acquire()).resolves.toMatchObject({ profile, ownerId: 'utc-owner' }); + const [leaseRows, wallRows] = await Promise.all([ + zonedConnector.prisma.$queryRaw>` + SELECT lease_until AS "leaseUntil" + FROM turn_daemon_lease + WHERE profile = ${profile} + `, + zonedConnector.prisma.$queryRaw>` + SELECT current_setting('TimeZone') AS zone, + (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow" + `, + ]); + expect(wallRows[0]?.zone).toBe('Asia/Seoul'); + expect(leaseRows[0]!.leaseUntil.getTime() - wallRows[0]!.wallNow.getTime()).toBeGreaterThan(50_000); + expect(leaseRows[0]!.leaseUntil.getTime() - wallRows[0]!.wallNow.getTime()).toBeLessThanOrEqual(60_000); + + await lease.release(); + const [releasedRows, releasedWall] = await Promise.all([ + zonedConnector.prisma.$queryRaw>` + SELECT lease_until AS "leaseUntil" + FROM turn_daemon_lease + WHERE profile = ${profile} + `, + zonedConnector.prisma.$queryRaw>` + SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow" + `, + ]); + expect(Math.abs(releasedRows[0]!.leaseUntil.getTime() - releasedWall[0]!.wallNow.getTime())).toBeLessThan( + 1_000 + ); + } finally { + await zonedConnector.disconnect(); + } + }); + it('increments the epoch on expiry takeover and fences the stale owner', async () => { const profile = `${profilePrefix}takeover`; const first = await createLease(profile, 'owner-a'); diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts index fd18c1e9..53879abc 100644 --- a/app/game-engine/test/unificationFinalization.integration.test.ts +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; -import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { GAME_TICKS_PER_TURN, GameClock, normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; +import { createGamePostgresConnector, createRedisConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic'; import { createAuctionBidder } from '../src/auction/bidder.js'; @@ -14,6 +14,10 @@ import { createMergeInheritPointRankHandler } from '../src/turn/monthlyUniqueInh import { loadPendingUnificationAuctionCancellations } from '../src/turn/unificationAuctionCancellation.js'; import { createUnificationHandler } from '../src/turn/unificationHandler.js'; import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; +import { reconcileClockSuspensionInTransaction } from '../src/turn/clockReconciliation.js'; +import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); @@ -22,15 +26,26 @@ const serverId = 'che_unification_atomicity_fixture'; const profileName = 'che'; const userId = 'unification-atomicity-user'; const legacyOfficerPicture = 'users/core/a369f064a434262b025bd2ebc70c60d5.jpg?=20260814'; +const invaderCityId = fixtureId + 1; +const invaderNationId = fixtureId + 1; +const invaderGeneralIds = Array.from({ length: 10 }, (_, index) => fixtureId + 1 + index); integration('unification finalization transaction', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; const cleanup = async (): Promise => { + await db.clockProjectionOutbox.deleteMany({ + where: { suspension: { worldState: { scenarioCode: 'unification-atomicity-fixture' } } }, + }); + await db.clockSuspension.deleteMany({ + where: { worldState: { scenarioCode: 'unification-atomicity-fixture' } }, + }); + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'unification-clock:' } } }); + await db.turnDaemonLease.deleteMany({ where: { profile: profileName } }); await db.message.deleteMany({ where: { mailbox: fixtureId } }); await db.auction.deleteMany({ where: { hostGeneralId: fixtureId } }); - await db.event.deleteMany({ where: { id: fixtureId } }); + await db.event.deleteMany({ where: { id: { in: [fixtureId, fixtureId + 1, fixtureId + 2] } } }); await db.unificationFinalization.deleteMany({ where: { serverId } }); await db.yearbookHistory.deleteMany({ where: { profileName: serverId } }); await db.emperor.deleteMany({ where: { serverId } }); @@ -44,10 +59,15 @@ integration('unification finalization transaction', () => { await db.logEntry.deleteMany({ where: { OR: [{ generalId: fixtureId }, { year: 190, month: 7 }] }, }); - await db.rankData.deleteMany({ where: { generalId: fixtureId } }); - await db.general.deleteMany({ where: { id: fixtureId } }); - await db.city.deleteMany({ where: { id: fixtureId } }); - await db.nation.deleteMany({ where: { id: fixtureId } }); + await db.generalTurn.deleteMany({ where: { generalId: { in: invaderGeneralIds } } }); + await db.nationTurn.deleteMany({ where: { nationId: invaderNationId } }); + await db.diplomacy.deleteMany({ + where: { OR: [{ srcNationId: invaderNationId }, { destNationId: invaderNationId }] }, + }); + await db.rankData.deleteMany({ where: { generalId: { in: [fixtureId, ...invaderGeneralIds] } } }); + await db.general.deleteMany({ where: { id: { in: [fixtureId, ...invaderGeneralIds] } } }); + await db.city.deleteMany({ where: { id: { in: [fixtureId, invaderCityId] } } }); + await db.nation.deleteMany({ where: { id: { in: [fixtureId, invaderNationId] } } }); await db.worldState.deleteMany({ where: { scenarioCode: 'unification-atomicity-fixture' } }); }; @@ -90,7 +110,31 @@ integration('unification finalization transaction', () => { id: fixtureId, name: '원자도시', nationId: fixtureId, - level: 1, + level: 3, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + supplyState: 1, + frontState: 0, + region: 1, + meta: { state: 0 }, + }, + }); + await db.city.create({ + data: { + id: invaderCityId, + name: '남만', + nationId: fixtureId, + level: 4, population: 1_000, populationMax: 2_000, agriculture: 100, @@ -249,6 +293,9 @@ integration('unification finalization transaction', () => { season: 1, scenarioId: 2, refreshLimit: 2, + maxGeneralsPerMinute: 1, + lastGeneralId: fixtureId, + lastNationId: fixtureId, scenarioMeta: { title: '원자성 시나리오', startYear: 190, @@ -265,7 +312,17 @@ integration('unification finalization transaction', () => { const bidWorld = new InMemoryTurnWorld(beforeBid.state, beforeBid.snapshot, { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, }); + const bidProcessingTick = bidWorld.getGameClockState().tick; + const futureCloseTick = BigInt(bidProcessingTick) + 86_400_000n; + await db.auction.updateMany({ + where: { id: { in: [uniqueAuction.id, resourceAuction.id] } }, + data: { closeTick: futureCloseTick }, + }); const bidder = await createAuctionBidder({ databaseUrl: databaseUrl!, world: bidWorld }); + const bidClockContext = { + processingGameTick: bidProcessingTick, + requestedAtWall: new Date('2026-09-03T00:00:00.000Z'), + }; try { await expect( bidder.bid({ @@ -275,7 +332,8 @@ integration('unification finalization transaction', () => { generalId: fixtureId, amount: 30, tryExtendCloseDate: false, - }) + ...bidClockContext, + } as Parameters[0]) ).resolves.toMatchObject({ ok: true, auctionId: uniqueAuction.id }); await expect( bidder.bid({ @@ -285,7 +343,8 @@ integration('unification finalization transaction', () => { generalId: fixtureId, amount: 50, tryExtendCloseDate: false, - }) + ...bidClockContext, + } as Parameters[0]) ).resolves.toMatchObject({ ok: true, auctionId: uniqueAuction.id }); } finally { await bidder.close(); @@ -311,6 +370,38 @@ integration('unification finalization transaction', () => { expect.objectContaining({ inheritSpentTrackedAmount: 50 }), ]); + const clockBaseTime = new Date('0190-01-01T00:00:00.000Z'); + const clockWallAnchor = new Date('2030-01-01T00:00:00.000Z'); + const fixtureClock = new GameClock({ + baseTime: clockBaseTime, + tick: 0, + mode: 'realtime', + wallAnchor: clockWallAnchor, + turnSeconds: 600, + phase: 'RUNNING', + revision: 1, + }); + const initialClockTick = fixtureClock.dateToTick(new Date('0190-06-01T00:00:00.000Z')); + await db.worldState.update({ + where: { id: worldRow.id }, + data: { + clockBaseTime, + clockTick: BigInt(initialClockTick), + clockMode: 'realtime', + clockWallAnchor, + lastTurnTick: BigInt(initialClockTick), + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, + }, + }); + await db.auction.updateMany({ + where: { id: { in: [uniqueAuction.id, resourceAuction.id] } }, + data: { + openTick: BigInt(initialClockTick), + closeTick: BigInt(fixtureClock.dateToTick(futureCloseAt)), + }, + }); const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); let world: InMemoryTurnWorld | null = null; const actions = new Map(); @@ -326,7 +417,8 @@ integration('unification finalization transaction', () => { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, calendarHandler: composeCalendarHandlers(events, unification.handler), }); - const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName }); + const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, maxNationTurns: 12 }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName, reservedTurns }); const stateManager = new EngineStateManager(); stateManager.register('world', { capture: () => world!.captureState(), @@ -343,6 +435,7 @@ integration('unification finalization transaction', () => { const beforeFailedTurn = world.captureState(); await expect( stateManager.transaction(async () => { + world!.advanceGameClockTo(new Date('0190-07-01T00:00:00.000Z'), clockWallAnchor); await world!.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); expect(world!.getState().meta).toMatchObject({ isUnited: 2, isunited: 2, refreshLimit: 200 }); expect(world!.peekDirtyState().pendingUnificationFinalizations).toHaveLength(1); @@ -381,6 +474,7 @@ integration('unification finalization transaction', () => { }, }); await stateManager.transaction(async () => { + world!.advanceGameClockTo(new Date('0190-07-01T00:00:00.000Z'), clockWallAnchor); await world!.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); await hooks.hooks.flushChanges?.(runResult); }); @@ -512,7 +606,208 @@ integration('unification finalization transaction', () => { expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({ currentYear: 190, currentMonth: 7, + clockPhase: 'SUSPENDED', }); + const suspension = await db.clockSuspension.findFirstOrThrow({ where: { worldStateId: worldRow.id } }); + expect(suspension).toMatchObject({ + source: 'UNIFICATION_WAIT', + policy: 'EXACT', + status: 'SUSPENDED', + sourceRevision: 1n, + targetRevision: 2n, + }); + + const invaderPrompt = (await db.message.findMany({ where: { mailbox: fixtureId } })).find((row) => { + const payload = row.message as { option?: { action?: unknown } }; + return payload.option?.action === 'raiseInvader'; + }); + expect(invaderPrompt).toBeDefined(); + const requestId = 'unification-clock:raise-invader'; + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: 'messageRespond', + actorUserId: userId, + payload: { + type: 'messageRespond', + requestId, + userId, + generalId: fixtureId, + messageId: invaderPrompt!.id, + response: true, + }, + status: 'PROCESSING', + acceptedGameTick: suspension.cutTick, + acceptedClockRevision: suspension.sourceRevision, + acceptedDeadlineGeneration: 1n, + processingAt: new Date(), + processingGameTick: suspension.cutTick, + processingClockRevision: suspension.sourceRevision, + processingDeadlineGeneration: 999n, + lockedBy: 'unification-clock-fixture', + leaseUntil: new Date(Date.now() + 60_000), + attempts: 1, + }, + }); + const resumeWallAt = new Date(suspension.cutWallAt.getTime() + 36 * 60 * 60_000); + await db.turnDaemonLease.create({ + data: { + profile: profileName, + ownerId: 'unification-clock-fixture', + fencingEpoch: 1n, + leaseUntil: new Date(Date.now() + 60_000), + }, + }); + const commandHandler = createTurnDaemonCommandHandler({ + world, + reservedTurns, + scenarioMeta: loaded.snapshot.scenarioMeta, + map: loaded.snapshot.map, + loadArchivedNationMaxId: async () => fixtureId, + reconcileUnificationWait: (input) => + reconcileClockSuspensionInTransaction({ + ...input, + allowUnificationWait: true, + testResumeWallAt: resumeWallAt, + }), + }); + const command = { + type: 'messageRespond' as const, + requestId, + userId, + generalId: fixtureId, + messageId: invaderPrompt!.id, + response: true, + }; + const executeCommand = () => + hooks.hooks.executeCommand!(requestId, async (context) => { + const result = await commandHandler.handle(command, { + ...context, + clockOperationAuthority: { + kind: 'DAEMON', + profileName, + ownerId: 'unification-clock-fixture', + fencingEpoch: 1n, + }, + }); + if (!result) throw new Error('Fixture command was not handled.'); + return result; + }); + const beforeFailedInvader = world.captureState(); + await expect(stateManager.transaction(executeCommand)).rejects.toThrow( + 'Input event processing clock fence changed before commit' + ); + expect(world.captureState()).toEqual(beforeFailedInvader); + expect(await db.nation.count({ where: { id: invaderNationId } })).toBe(0); + expect(await db.clockProjectionOutbox.count({ where: { suspensionId: suspension.id } })).toBe(0); + expect(await db.clockSuspension.findUniqueOrThrow({ where: { id: suspension.id } })).toMatchObject({ + status: 'SUSPENDED', + sourceRevision: 1n, + }); + await db.inputEvent.update({ + where: { requestId }, + data: { processingDeadlineGeneration: 1n }, + }); + const commandResult = await stateManager.transaction(executeCommand); + expect(commandResult).toMatchObject({ type: 'messageRespond', ok: true, action: 'raiseInvader' }); + const selectedPromptAction = await db.messageAction.findUniqueOrThrow({ + where: { messageId: invaderPrompt!.id }, + }); + const siblingPromptActions = await db.messageAction.findMany({ + where: { + actionType: 'raiseInvader', + createdGameTick: selectedPromptAction.createdGameTick, + }, + }); + expect(siblingPromptActions.length).toBeGreaterThan(1); + expect( + siblingPromptActions.every( + (action) => + action.status === 'RESOLVED' && + action.resolvedGameTick !== null && + action.resolvedGameTick >= action.createdGameTick + ) + ).toBe(true); + const reconciledWorld = await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } }); + const appliedSuspension = await db.clockSuspension.findUniqueOrThrow({ where: { id: suspension.id } }); + expect(reconciledWorld).toMatchObject({ + clockPhase: 'RECONCILING', + clockRevision: 2n, + deadlineGeneration: 2n, + tickSeconds: 1_200, + meta: expect.objectContaining({ isUnited: 1, isunited: 1 }), + }); + expect(appliedSuspension).toMatchObject({ + status: 'RECONCILING', + gapTicks: BigInt(36 * 60 * 60 * 60_000), + shiftTicks: BigInt(36 * 60 * 60 * 60_000), + }); + expect(await db.nation.findUniqueOrThrow({ where: { id: invaderNationId } })).toMatchObject({ + name: 'ⓞ남만족', + capitalCityId: invaderCityId, + }); + expect(await db.general.count({ where: { id: { in: invaderGeneralIds } } })).toBe(10); + const invaderTurns = await db.general.findMany({ + where: { id: { in: invaderGeneralIds } }, + select: { turnTick: true }, + orderBy: { id: 'asc' }, + }); + expect( + invaderTurns.every((entry) => entry.turnTick !== null && entry.turnTick > reconciledWorld.clockTick!) + ).toBe(true); + expect( + invaderTurns.every( + (entry) => + entry.turnTick !== null && + entry.turnTick <= reconciledWorld.clockTick! + BigInt(GAME_TICKS_PER_TURN) + ) + ).toBe(true); + expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({ + status: 'SUCCEEDED', + processingClockRevision: 1n, + processingDeadlineGeneration: 1n, + }); + expect( + await db.clockProjectionOutbox.findFirstOrThrow({ where: { suspensionId: suspension.id } }) + ).toMatchObject({ status: 'PENDING', targetRevision: 2n }); + + if (process.env.REDIS_URL) { + const redis = createRedisConnector({ url: process.env.REDIS_URL }); + await redis.connect(); + const prefix = `sammo:${profileName}`; + try { + await redis.client.del([ + `${prefix}:clock:active-revision`, + `${prefix}:clock:deadline-generation`, + `${prefix}:clock:projection-checksum`, + `${prefix}:clock:phase`, + `${prefix}:auction:timer`, + `${prefix}:tournament:state`, + ]); + await redis.client.set(`${prefix}:clock:active-revision`, '1'); + await expect( + applyNextClockProjection({ db, redis: redis.client, workerId: 'unification-clock-fixture' }) + ).resolves.toBe('APPLIED'); + expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({ + clockPhase: 'RUNNING', + clockRevision: 2n, + deadlineGeneration: 2n, + }); + world.completeClockReconciliation(); + expect(world.getGameClockState().phase).toBe('RUNNING'); + } finally { + await redis.client.del([ + `${prefix}:clock:active-revision`, + `${prefix}:clock:deadline-generation`, + `${prefix}:clock:projection-checksum`, + `${prefix}:clock:phase`, + `${prefix}:auction:timer`, + `${prefix}:tournament:state`, + ]); + await redis.disconnect(); + } + } } finally { await hooks.close(); } diff --git a/app/game-engine/test/unificationHandler.test.ts b/app/game-engine/test/unificationHandler.test.ts index aabd524f..94c800b3 100644 --- a/app/game-engine/test/unificationHandler.test.ts +++ b/app/game-engine/test/unificationHandler.test.ts @@ -149,6 +149,8 @@ describe('unification handler', () => { currentMonth: 6, tickSeconds: 600, lastTurnTime: new Date('0190-06-01T00:00:00.000Z'), + clockMode: 'realtime', + clockPhase: 'RUNNING', meta: { serverId: 'server-1', refreshLimit: 2 }, }; const snapshot: TurnWorldSnapshot = { @@ -203,6 +205,8 @@ describe('unification handler', () => { currentGeneralCount: 1, }, }); + expect(world.getGameClockState().phase).toBe('SUSPENDED'); + expect(world.getState().meta.unificationClockSuspensionId).toMatch(/^unification-wait-[a-f0-9]{32}$/); expect(world.getGeneralById(1)).toMatchObject({ inheritancePoints: { previous: 150, unifier: 2007, tournament: 11 }, meta: { inherit_earned_dyn: 2162.1, inherit_earned: 2167.1, inherit_spent: 20 }, diff --git a/app/game-engine/test/unificationInvaderResume.test.ts b/app/game-engine/test/unificationInvaderResume.test.ts index e9b04cdc..4059382b 100644 --- a/app/game-engine/test/unificationInvaderResume.test.ts +++ b/app/game-engine/test/unificationInvaderResume.test.ts @@ -147,6 +147,9 @@ describe('HWE-shaped unification invader resume', () => { clockMode: 'realtime', clockWallAnchor: liveWallAnchor, lastTurnTick: 19_944_000_000, + clockPhase: 'SUSPENDED', + clockRevision: 1, + deadlineGeneration: 1, meta: { hiddenSeed: 'hwe-invader-resume-fixture', serverId: 'hwe:default_snapshot', @@ -154,6 +157,7 @@ describe('HWE-shaped unification invader resume', () => { isunited: 2, refreshLimit: 3_000, maxGeneralsPerMinute: 1_000, + unificationClockSuspensionId: 'unification-wait-fixture', }, }; const snapshot: TurnWorldSnapshot = { @@ -170,6 +174,7 @@ describe('HWE-shaped unification invader resume', () => { const world = new InMemoryTurnWorld(state, snapshot, { schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] }, }); + const addGeneral = vi.spyOn(world, 'addGeneral'); const reservedTurns = new InMemoryReservedTurnStore( { generalTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, @@ -197,18 +202,28 @@ describe('HWE-shaped unification invader resume', () => { actorUserId: recipient.userId, target: 'ENGINE', eventType: 'messageRespond', - createdAt: acceptedAt, + processingGameTick: 19_980_000_000n, })), }, - $queryRaw: vi.fn(async () => [ - { - id: 1014, - mailbox: recipient.id, - type: 'private', - validUntil: new Date('9999-12-31T00:00:00.000Z'), - message, - }, - ]), + messageAction: { updateMany: vi.fn(async () => ({ count: 1 })) }, + message: { updateMany: vi.fn(async () => ({ count: 1 })) }, + $queryRaw: vi + .fn() + .mockResolvedValueOnce([ + { + id: 1014, + mailbox: recipient.id, + type: 'private', + time: world.gameTickToDate(19_980_000_000), + validUntil: new Date('9999-12-31T00:00:00.000Z'), + actionType: 'raiseInvader', + actionStatus: 'PENDING', + createdGameTick: 19_980_000_000n, + expiresGameTick: null, + message, + }, + ]) + .mockResolvedValueOnce([{ id: 1014 }]), } as unknown as GamePrisma.TransactionClient; const queue = new InMemoryControlQueue(); queue.enqueue({ @@ -232,6 +247,18 @@ describe('HWE-shaped unification invader resume', () => { }, map, loadArchivedNationMaxId: async () => 57, + reconcileUnificationWait: async () => ({ + suspensionId: 'unification-wait-fixture', + phase: 'RECONCILING', + sourceRevision: 1, + targetRevision: 2, + deadlineGeneration: 2, + gapTicks: 108_000_000, + catchUpTicks: 0, + shiftTicks: 108_000_000, + alignedTick: 20_088_000_000, + resumeWallAt: acceptedAt, + }), }); const processor = new InMemoryTurnProcessor(world); const clock = new ManualClock(acceptedAt.getTime()); @@ -253,7 +280,19 @@ describe('HWE-shaped unification invader resume', () => { processor: { run }, commandHandler, hooks: { - executeCommand: async (_commandRequestId, execute) => execute({ db: commandDb }), + executeCommand: async (_commandRequestId, execute) => { + const result = await execute({ + db: commandDb, + clockOperationAuthority: { + kind: 'DAEMON', + profileName: 'hwe:default-snapshot', + ownerId: 'fixture-daemon', + fencingEpoch: 1n, + }, + }); + world.completeClockReconciliation(); + return result; + }, }, }, { @@ -264,10 +303,12 @@ describe('HWE-shaped unification invader resume', () => { await lifecycle.start(); - expect(commandDb.inputEvent.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ where: { requestId } }) - ); - expect(commandDb.$queryRaw).toHaveBeenCalledOnce(); + expect(commandDb.inputEvent.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { requestId } })); + expect(commandDb.$queryRaw).toHaveBeenCalledTimes(2); + expect(commandDb.messageAction.updateMany).toHaveBeenCalledWith({ + where: { messageId: { in: [1014] }, status: 'PENDING' }, + data: { status: 'RESOLVED', resolvedGameTick: 20_088_000_000n }, + }); expect(world.getState()).toMatchObject({ currentYear: 226, currentMonth: 4, @@ -275,6 +316,14 @@ describe('HWE-shaped unification invader resume', () => { }); expect(world.listNations().filter((entry) => entry.name.startsWith('ⓞ'))).toHaveLength(1); expect(world.listGenerals().filter((entry) => entry.npcState === 9)).toHaveLength(10); + const initialInvaderTurnTimes = addGeneral.mock.calls + .map(([general]) => general) + .filter((general) => general.npcState === 9) + .map((general) => general.turnTime.getTime()); + const alignedMonthlyBoundary = addMinutes(liveLastTurnTime, 3).getTime(); + expect(initialInvaderTurnTimes).toHaveLength(10); + expect(Math.min(...initialInvaderTurnTimes)).toBeGreaterThanOrEqual(alignedMonthlyBoundary); + expect(Math.max(...initialInvaderTurnTimes)).toBeLessThan(alignedMonthlyBoundary + 60_000); expect(run).toHaveBeenCalledTimes(2); expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([ '2026-08-20T06:24:58.611Z', 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-engine/tsdown.config.ts b/app/game-engine/tsdown.config.ts index 3e102d24..ee9a7ce5 100644 --- a/app/game-engine/tsdown.config.ts +++ b/app/game-engine/tsdown.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ 'scenario/gameCancellation': 'src/scenario/gameCancellation.ts', 'scenario/scenarioSeeder': 'src/scenario/scenarioSeeder.ts', 'scenario/unitSetLoader': 'src/scenario/unitSetLoader.ts', + 'turn/clockProjectionOutbox': 'src/turn/clockProjectionOutbox.ts', + 'turn/clockReconciliation': 'src/turn/clockReconciliation.ts', 'turn/databaseHooks': 'src/turn/databaseHooks.ts', 'turn/inMemoryWorld': 'src/turn/inMemoryWorld.ts', 'turn/monthlyDisasterAction': 'src/turn/monthlyDisasterAction.ts', 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 21c34707..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') { @@ -2750,6 +2750,19 @@ export const adminRouter = router({ }; await ctx.profiles.updateMeta(input.profileName, nextMeta); if (mappedStatus) { + if (input.action === 'PAUSE' || input.action === 'STOP') { + await ctx.orchestrator.transitionProfileClock( + input.profileName, + 'SUSPEND', + input.reason?.trim() || `gateway ${input.action.toLowerCase()} by ${adminAuth.user.id}` + ); + } else if (input.action === 'RESUME') { + await ctx.orchestrator.transitionProfileClock( + input.profileName, + 'RESUME', + input.reason?.trim() || `gateway resume by ${adminAuth.user.id}` + ); + } await ctx.profiles.updateStatus(input.profileName, mappedStatus); await ctx.orchestrator.reconcileNow(); const appliedActionRecord = { diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 6abb1a28..262a8de6 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -5,6 +5,12 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { stripVTControlCharacters } from 'node:util'; import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js'; +import { applyNextClockProjection } from '@sammo-ts/game-engine/turn/clockProjectionOutbox.js'; +import { + reconcileClockSuspension, + startClockSuspension, + type ClockOperationAuthority, +} from '@sammo-ts/game-engine/turn/clockReconciliation.js'; import { cancelGame as defaultCancelGame, GAME_CANCELLATION_GENERAL_MODES, @@ -17,6 +23,9 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common'; import { createGamePostgresConnector, createRedisConnector, + CLOCK_OPERATION_PERSISTENCE_LOCK, + GamePrisma, + acquireGameSchemaAdvisoryXactLock, resolvePostgresPoolMax, resolveRedisConfigFromEnv, } from '@sammo-ts/infra'; @@ -112,6 +121,12 @@ export interface GatewayOrchestratorOptions { fetchImpl?: typeof fetch; clearTournamentRuntimeState?: (profileName: string) => Promise; cancelGame?: typeof defaultCancelGame; + transitionProfileClock?: ( + profileName: string, + action: 'SUSPEND' | 'RESUME', + reason: string + ) => Promise<{ phase: string; revision: number }>; + promoteProfileOpening?: (profile: GatewayProfileRecord) => Promise; } const WORKSPACE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000; @@ -161,6 +176,11 @@ export interface GatewayOrchestratorHandle { }>; listRuntimeStates(profileNames: string[]): Promise; listRuntimeSettings?(profileNames: string[]): Promise; + transitionProfileClock( + profileName: string, + action: 'SUSPEND' | 'RESUME', + reason: string + ): Promise<{ phase: string; revision: number }>; } export interface GatewayManagedCleanupResult { @@ -202,6 +222,14 @@ export const planProfileReconcile = ( }; }; +/** + * A stopped process may be restarted while the world remains in unification + * wait. Only the daemon-authorized raise-invader response may reconcile that + * suspension, so Gateway RESUME must start the runtime without consuming it. + */ +export const shouldStartRuntimeInUnificationWait = (clockPhase: string, suspensionSource: string): boolean => + clockPhase === 'SUSPENDED' && suspensionSource === 'UNIFICATION_WAIT'; + export const resolveResetLifecycleStatus = ( now: Date, preopenAt: Date | null, @@ -286,11 +314,27 @@ export const buildTournamentRuntimeKeys = (profileName: string): string[] => [ `sammo:${profileName}:tournament:source-revision`, ]; +export const buildGameClockRuntimeKeys = (profileName: string): string[] => [ + `sammo:${profileName}:clock:active-revision`, + `sammo:${profileName}:clock:deadline-generation`, + `sammo:${profileName}:clock:phase`, +]; + +export const buildProfileResetRuntimeKeys = (profileName: string): string[] => [ + ...buildTournamentRuntimeKeys(profileName), + ...buildGameClockRuntimeKeys(profileName), +]; + export const clearTournamentRuntimeKeys = async ( redis: { del(keys: string[]): Promise }, profileName: string ): Promise => redis.del(buildTournamentRuntimeKeys(profileName)); +export const clearProfileResetRuntimeKeys = async ( + redis: { del(keys: string[]): Promise }, + profileName: string +): Promise => redis.del(buildProfileResetRuntimeKeys(profileName)); + const buildServerId = (profileName: string, now: Date, installOperationId?: string): string => { const year = String(now.getFullYear()).slice(-2); const month = String(now.getMonth() + 1).padStart(2, '0'); @@ -322,6 +366,14 @@ const readMetaNumber = (meta: Record, key: string): number | nu return null; }; +const clockRevisionAsNumber = (revision: bigint): number => { + const value = Number(revision); + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`Clock revision is outside the safe API integer range: ${revision.toString()}.`); + } + return value; +}; + export const resolveProfileFirstGameIdx = (meta: Record): number => { const raw = meta.firstGameIdx; const configured = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN; @@ -842,10 +894,7 @@ const assertProfileMigrationEnvironmentTimeZone = (env?: Record) if (pgTimeZone) assertProfileMigrationTimeZone(pgTimeZone, 'PGTZ'); }; -const buildProfileMigrationEnv = ( - profileDatabaseUrl: string, - env?: Record -): Record => { +const buildProfileMigrationEnv = (profileDatabaseUrl: string, env?: Record): Record => { assertProfileMigrationEnvironmentTimeZone(env); return { ...(env ?? {}), DATABASE_URL: buildProfileMigrationDatabaseUrl(profileDatabaseUrl) }; }; @@ -940,8 +989,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private readonly profileReadinessTimeoutMs: number; private readonly now: () => Date; private readonly fetchImpl: typeof fetch; + /** Backwards-compatible injection name; the default clears all season-owned RESET keys. */ private readonly clearTournamentRuntimeState: (profileName: string) => Promise; private readonly cancelGame: typeof defaultCancelGame; + private readonly transitionProfileClockOverride?: GatewayOrchestratorOptions['transitionProfileClock']; + private readonly promoteProfileOpeningOverride?: GatewayOrchestratorOptions['promoteProfileOpening']; private reconcileTimer?: NodeJS.Timeout; private scheduleTimer?: NodeJS.Timeout; private buildTimer?: NodeJS.Timeout; @@ -986,6 +1038,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { options.clearTournamentRuntimeState ?? ((profileName) => this.clearTournamentRuntimeStateFromRedis(profileName)); this.cancelGame = options.cancelGame ?? defaultCancelGame; + this.transitionProfileClockOverride = options.transitionProfileClock; + this.promoteProfileOpeningOverride = options.promoteProfileOpening; } private sanitizeOperationLogMessage(message: string): string { @@ -1171,6 +1225,194 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return snapshots.filter((snapshot): snapshot is ProfileRuntimeSettingsSnapshot => snapshot !== null); } + async transitionProfileClock( + profileName: string, + action: 'SUSPEND' | 'RESUME', + reason: string + ): Promise<{ phase: string; revision: number }> { + if (this.transitionProfileClockOverride) { + return this.transitionProfileClockOverride(profileName, action, reason); + } + const profile = await this.repository.getProfile(profileName); + if (!profile || profile.currentScenario === null) { + throw new Error(`Profile clock is unavailable: ${profileName}`); + } + const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) }); + const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env)); + await postgres.connect(); + await redis.connect(); + try { + const authorityRows = await postgres.prisma.$queryRaw< + Array<{ ownerId: string; fencingEpoch: bigint; valid: boolean }> + >(GamePrisma.sql` + SELECT owner_id AS "ownerId", + fencing_epoch AS "fencingEpoch", + lease_until > (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS valid + FROM turn_daemon_lease + WHERE profile = ${profileName} + `); + const liveLease = authorityRows.find((row) => row.valid); + const authority: ClockOperationAuthority = liveLease + ? { + kind: 'DAEMON', + profileName, + ownerId: liveLease.ownerId, + fencingEpoch: liveLease.fencingEpoch, + } + : { kind: 'OFFLINE', profileName, reason }; + const world = await postgres.prisma.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { clockPhase: true, clockRevision: true, deadlineGeneration: true }, + }); + if (!world) throw new Error(`Profile has no world_state: ${profileName}`); + + if (action === 'SUSPEND') { + let suspension = await postgres.prisma.clockSuspension.findFirst({ + where: { sourceRevision: world.clockRevision, status: 'SUSPENDED' }, + orderBy: { createdAt: 'desc' }, + }); + if (world.clockPhase === 'RUNNING') { + const suffix = createHash('sha256') + .update(`${profileName}:${world.clockRevision.toString()}`) + .digest('hex') + .slice(0, 20); + const started = await startClockSuspension({ + db: postgres.prisma, + suspensionId: `gateway-maintenance-${suffix}`, + source: 'MAINTENANCE', + authority, + }); + suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({ + where: { id: started.suspensionId }, + }); + } else if (world.clockPhase !== 'SUSPENDED') { + throw new Error(`Cannot suspend profile clock from ${world.clockPhase}.`); + } + if (!suspension) throw new Error('Suspended profile is missing its durable clock ledger.'); + const phaseResult = await redis.client.eval( + ` + local active = redis.call('GET', KEYS[1]) + if active and active ~= ARGV[1] then return 0 end + redis.call('SET', KEYS[1], ARGV[1]) + redis.call('SET', KEYS[2], ARGV[2]) + redis.call('SET', KEYS[3], 'SUSPENDED') + return 1 + `, + { + keys: [ + `sammo:${profileName}:clock:active-revision`, + `sammo:${profileName}:clock:deadline-generation`, + `sammo:${profileName}:clock:phase`, + ], + arguments: [world.clockRevision.toString(), world.deadlineGeneration.toString()], + } + ); + if (Number(phaseResult) !== 1) throw new Error('Redis clock source revision differs from the DB.'); + return { phase: 'SUSPENDED', revision: clockRevisionAsNumber(suspension.sourceRevision) }; + } + + if (world.clockPhase === 'RUNNING') { + return { phase: 'RUNNING', revision: clockRevisionAsNumber(world.clockRevision) }; + } + const suspension = await postgres.prisma.clockSuspension.findFirst({ + where: { status: { in: ['SUSPENDED', 'RECONCILING'] } }, + orderBy: { createdAt: 'desc' }, + }); + if (!suspension) throw new Error('Profile resume requires a durable suspended clock ledger.'); + if (shouldStartRuntimeInUnificationWait(world.clockPhase, suspension.source)) { + return { phase: 'SUSPENDED', revision: clockRevisionAsNumber(world.clockRevision) }; + } + if (world.clockPhase === 'SUSPENDED') { + await reconcileClockSuspension({ db: postgres.prisma, suspensionId: suspension.id, authority }); + } else if (world.clockPhase !== 'RECONCILING') { + throw new Error(`Cannot resume profile clock from ${world.clockPhase}.`); + } + const projected = await applyNextClockProjection({ + db: postgres.prisma, + redis: redis.client, + workerId: `gateway:${this.operationLeaseOwner}`, + }); + if (projected === 'IDLE') throw new Error('Clock reconciliation has no claimable projection outbox.'); + const resumed = await postgres.prisma.worldState.findFirstOrThrow({ + orderBy: { id: 'asc' }, + select: { clockPhase: true, clockRevision: true }, + }); + if (resumed.clockPhase !== 'RUNNING') throw new Error('Clock projection did not reach RUNNING.'); + return { phase: resumed.clockPhase, revision: clockRevisionAsNumber(resumed.clockRevision) }; + } finally { + await redis.disconnect().catch(() => undefined); + await postgres.disconnect().catch(() => undefined); + } + } + + private async promoteProfileOpening(profile: GatewayProfileRecord): Promise { + if (this.promoteProfileOpeningOverride) { + await this.promoteProfileOpeningOverride(profile); + return; + } + const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) }); + const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env)); + await postgres.connect(); + await redis.connect(); + try { + const clock = await postgres.prisma.$transaction(async (transaction) => { + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + const rows = await transaction.$queryRaw>(GamePrisma.sql` + SELECT id FROM world_state ORDER BY id LIMIT 2 FOR UPDATE + `); + if (rows.length !== 1) { + throw new Error(`Opening promotion requires exactly one world_state row; found ${rows.length}.`); + } + const world = await transaction.worldState.findUniqueOrThrow({ where: { id: rows[0]!.id } }); + if (world.clockPhase === 'RUNNING') { + return { revision: world.clockRevision, generation: world.deadlineGeneration }; + } + if (world.clockPhase !== 'PREOPEN' || world.clockTick !== 0n || !world.clockWallAnchor) { + throw new Error( + `Opening promotion requires PREOPEN at tick 0; found ${world.clockPhase}@${world.clockTick?.toString() ?? 'null'}.` + ); + } + const [wall] = await transaction.$queryRaw>(GamePrisma.sql` + SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow" + `); + if (!wall || wall.wallNow.getTime() < world.clockWallAnchor.getTime()) { + throw new Error('Opening promotion was requested before the scheduled wall instant.'); + } + await transaction.worldState.update({ + where: { id: world.id }, + data: { clockPhase: 'RUNNING' }, + }); + return { revision: world.clockRevision, generation: world.deadlineGeneration }; + }); + const result = await redis.client.eval( + ` + local revision = redis.call('GET', KEYS[1]) + local generation = redis.call('GET', KEYS[2]) + if revision and revision ~= ARGV[1] then return 0 end + if generation and generation ~= ARGV[2] then return 0 end + redis.call('SET', KEYS[1], ARGV[1]) + redis.call('SET', KEYS[2], ARGV[2]) + redis.call('SET', KEYS[3], 'RUNNING') + return 1 + `, + { + keys: [ + `sammo:${profile.profileName}:clock:active-revision`, + `sammo:${profile.profileName}:clock:deadline-generation`, + `sammo:${profile.profileName}:clock:phase`, + ], + arguments: [clock.revision.toString(), clock.generation.toString()], + } + ); + if (Number(result) !== 1) { + throw new Error('Opening promotion found a different Redis clock revision or generation.'); + } + } finally { + await redis.disconnect().catch(() => undefined); + await postgres.disconnect().catch(() => undefined); + } + } + async reconcileNow(): Promise { if (this.stopping || this.reconcileInFlight) { return; @@ -1232,14 +1474,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { continue; } if (profile.currentScenario !== null && profile.buildStatus === 'SUCCEEDED' && profile.buildWorkspace) { - await this.repository.updateStatus( - profile.profileName, - resolveResetLifecycleStatus(now, preopenAt, openAt), - { - preopenAt: profile.preopenAt, - openAt: profile.openAt, - } - ); + const nextStatus = resolveResetLifecycleStatus(now, preopenAt, openAt); + if (nextStatus === 'RUNNING') { + await this.promoteProfileOpening(profile); + } + await this.repository.updateStatus(profile.profileName, nextStatus, { + preopenAt: profile.preopenAt, + openAt: profile.openAt, + }); await this.repository.updateLastError(profile.profileName, null); continue; } @@ -1255,6 +1497,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const profiles = await this.repository.listProfiles(); for (const profile of profiles) { if (profile.status === 'PREOPEN' && profile.openAt && new Date(profile.openAt) <= now) { + await this.promoteProfileOpening(profile); await this.repository.updateStatus(profile.profileName, 'RUNNING', { preopenAt: profile.preopenAt ?? null, openAt: profile.openAt ?? null, @@ -1301,16 +1544,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { error: null, }); if (queued.status === 'RESERVED') { - await this.repository.updateStatus( - queued.profileName, - queued.openAt && new Date(queued.openAt) <= this.now() ? 'RUNNING' : 'PREOPEN', - { - preopenAt: queued.preopenAt ?? null, - openAt: queued.openAt ?? null, - } - ); + const opensImmediately = Boolean(queued.openAt && new Date(queued.openAt) <= this.now()); + if (opensImmediately) { + await this.promoteProfileOpening(queued); + } + await this.repository.updateStatus(queued.profileName, opensImmediately ? 'RUNNING' : 'PREOPEN', { + preopenAt: queued.preopenAt ?? null, + openAt: queued.openAt ?? null, + }); } else if (queued.status === 'PREOPEN' && queued.openAt) { if (new Date(queued.openAt) <= this.now()) { + await this.promoteProfileOpening(queued); await this.repository.updateStatus(queued.profileName, 'RUNNING', { preopenAt: queued.preopenAt ?? null, openAt: queued.openAt ?? null, @@ -2563,7 +2807,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const connector = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env)); await connector.connect(); try { - await clearTournamentRuntimeKeys(connector.client, profileName); + await clearProfileResetRuntimeKeys(connector.client, profileName); } finally { await connector.disconnect(); } @@ -2779,7 +3023,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') @@ -2792,7 +3036,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/orchestrator/seedProfileDatabase.ts b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts index b3f61aff..a1e856f2 100644 --- a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts +++ b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts @@ -1,6 +1,14 @@ import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js'; import type { GamePrisma } from '@sammo-ts/infra'; -import { GameClock, asRecord, type GameClockMode } from '@sammo-ts/common'; +import { + GameClock, + asObservedGameInstant, + asRecord, + inferClockPhase, + parseGameClockPhase, + scheduleNotBefore, + type GameClockMode, +} from '@sammo-ts/common'; export interface AdminSeedUser { id: string; @@ -105,14 +113,22 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse const name = await resolveAdminName(prisma, adminUser); const meta = asRecord(worldState.meta); const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null; - const turnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date(); + const fallbackTurnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date(); + const mode = worldState.clockMode === 'manual' ? 'manual' : 'realtime'; + const phase = worldState.clockPhase + ? parseGameClockPhase(worldState.clockPhase) + : inferClockPhase(mode); const gameClock = new GameClock({ - baseTime: worldState.clockBaseTime ?? turnTime, + baseTime: worldState.clockBaseTime ?? fallbackTurnTime, tick: Number(worldState.clockTick ?? 0n), - mode: worldState.clockMode === 'manual' ? 'manual' : 'realtime', - wallAnchor: worldState.clockWallAnchor ?? turnTime, + mode, + wallAnchor: worldState.clockWallAnchor ?? fallbackTurnTime, turnSeconds: worldState.tickSeconds, + phase, + revision: Number(worldState.clockRevision ?? 1n), }); + const turnTick = scheduleNotBefore(asObservedGameInstant(gameClock.nowTick(new Date())), phase); + const turnTime = gameClock.tickToDate(turnTick); await prisma.general.create({ data: { @@ -130,7 +146,7 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse specialCode: 'None', special2Code: 'None', turnTime, - turnTick: BigInt(gameClock.dateToTick(turnTime)), + turnTick: BigInt(turnTick), meta: { createdBy: 'admin-seed', killturn: 24, 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/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index f0972c3a..96bdfb12 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -87,6 +87,7 @@ const buildCaller = async ( const updatedStatuses: GatewayProfileRecord['status'][] = []; const updatedMetas: Record[] = []; const auditEvents: AdminAuditEventRecord[] = []; + const lifecycle: string[] = []; let reconcileCount = 0; let runtimeStateListCount = 0; let storedNotice = options.initialNotice ?? ''; @@ -111,6 +112,7 @@ const buildCaller = async ( updateCurrentScenario: async () => profile, updateStatus: async (_profileName, status) => { updatedStatuses.push(status); + lifecycle.push(`status:${status}`); return { ...profile, status }; }, updateBuildStatus: async () => profile, @@ -288,6 +290,7 @@ const buildCaller = async ( stop: async () => {}, reconcileNow: async () => { reconcileCount += 1; + lifecycle.push('runtime:reconcile'); }, runScheduleNow: async () => {}, runBuildQueueNow: async () => {}, @@ -297,6 +300,13 @@ const buildCaller = async ( } }, cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }), + transitionProfileClock: async (_profileName, action) => { + lifecycle.push(`clock:${action}`); + return { + phase: action === 'SUSPEND' ? 'SUSPENDED' : 'RUNNING', + revision: 1, + }; + }, listRuntimeSettings: async () => [ { profileName: 'che:2', @@ -363,6 +373,7 @@ const buildCaller = async ( updatedStatuses, updatedMetas, auditEvents, + lifecycle, getReconcileCount: () => reconcileCount, getRuntimeStateListCount: () => runtimeStateListCount, getStoredNotice: () => storedNotice, @@ -1384,6 +1395,7 @@ describe('admin runtime clock action API', () => { }) ).resolves.toMatchObject({ ok: true }); expect(harness.updatedStatuses).toEqual(['RUNNING']); + expect(harness.lifecycle).toEqual(['clock:RESUME', 'status:RUNNING', 'runtime:reconcile']); expect(harness.getReconcileCount()).toBe(1); expect(harness.updatedMetas).toHaveLength(2); expect(harness.updatedMetas.at(-1)).toMatchObject({ @@ -1410,6 +1422,11 @@ describe('admin runtime clock action API', () => { }); expect(harness.updatedStatuses).toEqual([expectedStatus]); + expect(harness.lifecycle).toEqual( + action === 'STOP' + ? ['clock:SUSPEND', `status:${expectedStatus}`, 'runtime:reconcile'] + : [`status:${expectedStatus}`, 'runtime:reconcile'] + ); expect(harness.getReconcileCount()).toBe(1); expect(harness.updatedMetas).toHaveLength(2); expect(harness.updatedMetas[0]).toMatchObject({ diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index 86d71fbc..3df14b75 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -123,6 +123,10 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin. runBuildQueueNow: async () => {}, runOperationsNow: async () => {}, cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }), + transitionProfileClock: async (_profileName, action) => ({ + phase: action === 'SUSPEND' ? 'SUSPENDED' : 'RUNNING', + revision: 1, + }), listRuntimeStates: async () => [], }, profileStatus: new InMemoryProfileStatusService(), @@ -183,9 +187,7 @@ describe('admin security over HTTP transport', () => { }, }); - const mutationInput = encodeURIComponent( - JSON.stringify({ json: { sessionToken: harness.adminSessionToken } }) - ); + const mutationInput = encodeURIComponent(JSON.stringify({ json: { sessionToken: harness.adminSessionToken } })); const mutation = await fetch(`${harness.baseUrl}/trpc/auth.logout?input=${mutationInput}`); expect(mutation.status).toBe(405); expect(await mutation.json()).toMatchObject({ diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index de7195c6..28ab40ca 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -168,6 +168,10 @@ const buildCaller = ( removed: [], skipped: [], }), + transitionProfileClock: async (_profileName: string, action: 'SUSPEND' | 'RESUME') => ({ + phase: action === 'SUSPEND' ? 'SUSPENDED' : 'RUNNING', + revision: 1, + }), listRuntimeStates: async () => [], }; const profileStatus = new InMemoryProfileStatusService( 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/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 5740e5a5..a3b3b5f9 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -65,6 +65,7 @@ const createHarness = ( frontendServeMode?: 'static'; frontendArtifactRoot?: string; activeOperationProfileNames?: string[]; + promoteProfileOpening?: GatewayOrchestratorOptions['promoteProfileOpening']; } = {} ) => { const harnessProfile = options.profile ?? profile; @@ -77,6 +78,7 @@ const createHarness = ( const deleted: string[] = []; const buildStatuses: string[] = []; const logs: Array<{ phase: string; message: string; level: string }> = []; + const lifecycle: string[] = []; const repository: GatewayProfileRepository = { listProfiles: async () => options.profiles ?? [harnessProfile], @@ -85,6 +87,7 @@ const createHarness = ( updateCurrentScenario: async () => harnessProfile, updateStatus: async (_profileName, status) => { statuses.push(status); + lifecycle.push(`status:${status}`); return { ...harnessProfile, status }; }, updateBuildStatus: async (_profileName, status) => { @@ -192,9 +195,26 @@ const createHarness = ( adminActionIntervalMs: 60_000, now: options.now, cancelGame: options.cancelGame, + promoteProfileOpening: options.promoteProfileOpening + ? async (openingProfile) => { + lifecycle.push(`clock:${openingProfile.profileName}`); + await options.promoteProfileOpening?.(openingProfile); + } + : undefined, }); - return { orchestrator, statuses, buildStatuses, completions, completionFields, started, stopped, deleted, logs }; + return { + orchestrator, + statuses, + buildStatuses, + completions, + completionFields, + started, + stopped, + deleted, + logs, + lifecycle, + }; }; describe('GatewayOrchestrator first-class operations', () => { @@ -333,12 +353,14 @@ describe('GatewayOrchestrator first-class operations', () => { profiles: [], reservedToStart: [reservedProfile], now: () => now, + promoteProfileOpening: async () => {}, }); await harness.orchestrator.runScheduleNow(); expect(harness.statuses).toEqual(['RUNNING']); expect(harness.buildStatuses).toEqual([]); + expect(harness.lifecycle).toEqual([`clock:${reservedProfile.profileName}`, 'status:RUNNING']); }); it('retains the legacy build queue for an unprepared reserved profile', async () => { @@ -366,6 +388,26 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.buildStatuses).toEqual(['QUEUED']); }); + it('promotes the durable clock before a prepared preopen profile becomes running', async () => { + const now = new Date('2030-01-01T02:00:00.000Z'); + const preopenProfile: GatewayProfileRecord = { + ...profile, + status: 'PREOPEN', + openAt: now.toISOString(), + preopenAt: '2030-01-01T01:00:00.000Z', + }; + const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, { + profile: preopenProfile, + profiles: [preopenProfile], + now: () => now, + promoteProfileOpening: async () => {}, + }); + + await harness.orchestrator.runScheduleNow(); + + expect(harness.lifecycle).toEqual([`clock:${preopenProfile.profileName}`, 'status:RUNNING']); + }); + it('starts every profile process and records success', async () => { const harness = createHarness(buildOperation('START')); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 8e1c526b..dcd79c4e 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -13,6 +13,7 @@ import { planProfileReconcile, resolveProfileArchiveServerName, resolveResetLifecycleStatus, + shouldStartRuntimeInUnificationWait, } from '../src/orchestrator/gatewayOrchestrator.js'; import { GATEWAY_PROFILE_ORDER } from '../src/profileOrder.js'; import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js'; @@ -114,6 +115,20 @@ describe('planProfileReconcile', () => { }); }); +describe('shouldStartRuntimeInUnificationWait', () => { + it('starts a stopped runtime without consuming its daemon-authorized unification suspension', () => { + expect(shouldStartRuntimeInUnificationWait('SUSPENDED', 'UNIFICATION_WAIT')).toBe(true); + }); + + it.each([ + ['SUSPENDED', 'MAINTENANCE'], + ['RECONCILING', 'UNIFICATION_WAIT'], + ['RUNNING', 'UNIFICATION_WAIT'], + ])('keeps ordinary resume reconciliation for %s / %s', (phase, source) => { + expect(shouldStartRuntimeInUnificationWait(phase, source)).toBe(false); + }); +}); + describe('resolveResetLifecycleStatus', () => { const now = new Date('2030-01-01T00:00:00.000Z'); @@ -471,7 +486,9 @@ describe('buildWorkspaceCommands', () => { ); const migrationUrl = new URL(command.env?.DATABASE_URL ?? ''); - expect(migrationUrl.searchParams.getAll('options')).toEqual(['-c statement_timeout=30000 -c TimeZone=Asia/Seoul']); + expect(migrationUrl.searchParams.getAll('options')).toEqual([ + '-c statement_timeout=30000 -c TimeZone=Asia/Seoul', + ]); }); it('keeps an already explicit KST migration contract without adding another override', () => { 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 a08dbb67..50a0cb49 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: '20260824080000_vote_utc_wall_timestamps', + gameSchemaHead: '20260903201500_complete_invader_game_clock', }); }); diff --git a/app/gateway-api/test/tournamentResetState.test.ts b/app/gateway-api/test/tournamentResetState.test.ts index d45ac5ea..b99d0882 100644 --- a/app/gateway-api/test/tournamentResetState.test.ts +++ b/app/gateway-api/test/tournamentResetState.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + buildGameClockRuntimeKeys, + buildProfileResetRuntimeKeys, buildTournamentRuntimeKeys, + clearProfileResetRuntimeKeys, clearTournamentRuntimeKeys, } from '../src/orchestrator/gatewayOrchestrator.js'; @@ -32,4 +35,31 @@ describe('tournament reset state', () => { expect(deleted).toBe(5); expect(calls).toEqual([buildTournamentRuntimeKeys('che:1010')]); }); + + it('clears stale clock authority together with tournament projection on season reset', async () => { + expect(buildGameClockRuntimeKeys('hwe:default')).toEqual([ + 'sammo:hwe:default:clock:active-revision', + 'sammo:hwe:default:clock:deadline-generation', + 'sammo:hwe:default:clock:phase', + ]); + expect(buildProfileResetRuntimeKeys('hwe:default')).toEqual([ + ...buildTournamentRuntimeKeys('hwe:default'), + ...buildGameClockRuntimeKeys('hwe:default'), + ]); + + const calls: string[][] = []; + const deleted = await clearProfileResetRuntimeKeys( + { + del: async (keys) => { + calls.push(keys); + return keys.length; + }, + }, + 'hwe:default' + ); + + expect(deleted).toBe(8); + expect(calls).toEqual([buildProfileResetRuntimeKeys('hwe:default')]); + expect(calls[0]).not.toContain('sammo:che:default:clock:phase'); + }); }); 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 new file mode 100644 index 00000000..3483fabc --- /dev/null +++ b/docs/architecture/game-clock-participants.json @@ -0,0 +1,317 @@ +{ + "schemaVersion": 1, + "authority": "game-tick", + "tickPerTurn": 36000000, + "policies": ["SHIFT", "KEEP", "REBUILD", "FORBID"], + "coveredFields": [ + "input_event.accepted_game_tick", + "input_event.accepted_clock_revision", + "input_event.accepted_deadline_generation", + "input_event.processing_game_tick", + "input_event.processing_clock_revision", + "input_event.processing_deadline_generation", + "world_state.clock_tick", + "world_state.last_turn_tick", + "world_state.clock_revision", + "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", + "clock_suspension.target_revision", + "clock_suspension.cut_tick", + "clock_suspension.catch_up_ticks", + "clock_suspension.gap_ticks", + "clock_suspension.shift_ticks", + "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", + "policy": "REBUILD", + "authorityFields": ["world_state.clock_tick", "world_state.clock_revision"], + "projectionFields": ["world_state.clock_base_time", "world_state.clock_wall_anchor"], + "owner": "game-engine/clock-operation" + }, + { + "key": "turn-cursor", + "policy": "SHIFT", + "authorityFields": ["world_state.last_turn_tick"], + "projectionFields": ["world_state.meta.lastTurnTime", "in-memory.checkpoint.turnTime"], + "owner": "game-engine/turn-daemon" + }, + { + "key": "general-next-turn", + "policy": "SHIFT", + "authorityFields": ["general.turn_tick"], + "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", + "authorityFields": ["general.recent_war_tick"], + "projectionFields": ["general.recent_war_time"], + "owner": "game-engine/battle" + }, + { + "key": "auction-open-occurrence", + "policy": "KEEP", + "authorityFields": ["auction.open_tick"], + "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", + "policy": "SHIFT", + "authorityFields": ["auction.close_tick"], + "projectionFields": ["auction.close_at"], + "owner": "game-api/auction-worker" + }, + { + "key": "auction-finalizing-recovery", + "policy": "REBUILD", + "authorityFields": ["auction.status", "world_state.deadline_generation"], + "projectionFields": ["redis.auction.timer"], + "owner": "game-api/auction-worker" + }, + { + "key": "message-action-occurrence", + "policy": "KEEP", + "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-action-expiry", + "policy": "SHIFT", + "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", + "policy": "KEEP", + "authorityFields": ["vote_poll.start_tick"], + "projectionFields": ["vote_poll.start_at"], + "owner": "game-api/vote" + }, + { + "key": "vote-end-deadline", + "policy": "SHIFT", + "authorityFields": ["vote_poll.end_tick"], + "projectionFields": ["vote_poll.end_at"], + "owner": "game-api/vote" + }, + { + "key": "select-pool-reservation", + "policy": "SHIFT", + "authorityFields": ["select_pool.reserved_until_tick"], + "projectionFields": ["select_pool.reserved_until"], + "owner": "game-engine/select-pool" + }, + { + "key": "npc-selection-window", + "policy": "SHIFT", + "authorityFields": ["select_npc_token.valid_until_tick", "select_npc_token.pick_more_from_tick"], + "projectionFields": ["select_npc_token.valid_until", "select_npc_token.pick_more_from"], + "owner": "game-engine/npc-selection" + }, + { + "key": "daemon-command-coordinate", + "policy": "KEEP", + "authorityFields": [ + "input_event.accepted_game_tick", + "input_event.accepted_clock_revision", + "input_event.accepted_deadline_generation" + ], + "projectionFields": [ + "input_event.processing_game_tick", + "input_event.processing_clock_revision", + "input_event.processing_deadline_generation" + ], + "owner": "game-engine/input-event-claim" + }, + { + "key": "tournament-deadlines", + "policy": "REBUILD", + "authorityFields": ["redis.tournament.state.nextTick", "redis.tournament.state.bettingCloseTick"], + "projectionFields": ["redis.tournament.state.nextAt", "redis.tournament.state.bettingCloseAt"], + "owner": "game-api/tournament-worker", + "migration": "Redis-only legacy dates must dual-write ticks before exact reconciliation is enabled." + }, + { + "key": "movable-json-rule-anchors", + "policy": "SHIFT", + "authorityFields": [ + "world_state.meta.turntime", + "world_state.meta.starttime", + "world_state.meta.tnmt_time" + ], + "projectionFields": [], + "owner": "game-engine/clock-operation", + "migration": "Explicit adapter shifts registered ISO projections while typed tick columns remain authoritative." + }, + { + "key": "unification-wait", + "policy": "REBUILD", + "authorityFields": [ + "world_state.meta.isunited", + "world_state.meta.unificationClockSuspensionId", + "clock_suspension.cut_tick", + "clock_suspension.cut_wall_at" + ], + "projectionFields": ["world_state.meta.lastTurnTime"], + "owner": "game-engine/unification", + "migration": "Implemented as one exact alignment, optional rate change, invader creation, and revisioned outbox transaction." + }, + { + "key": "clock-operation-ledger", + "policy": "KEEP", + "authorityFields": [ + "clock_suspension.source_revision", + "clock_suspension.target_revision", + "clock_suspension.cut_tick", + "clock_suspension.catch_up_ticks", + "clock_suspension.gap_ticks", + "clock_suspension.shift_ticks", + "clock_suspension.aligned_tick", + "clock_projection_outbox.target_revision" + ], + "projectionFields": [], + "owner": "game-engine/clock-operation" + } + ], + "redis": [ + { + "keyPattern": "sammo:{profile}:clock:active-revision", + "policy": "REBUILD", + "status": "implemented-with-db-phase-and-deadline-generation-fence" + }, + { + "keyPattern": "sammo:{profile}:auction:timer", + "policy": "REBUILD", + "status": "implemented-with-clock-revision-phase-generation-fence" + }, + { + "keyPattern": "sammo:{profile}:tournament:state", + "policy": "REBUILD", + "status": "implemented-with-tick-dual-write-and-clock-fence" + } + ], + "wallOnly": [ + "input_event.created_at", + "input_event.processing_at", + "input_event.completed_at", + "input_event.lease_until", + "turn_daemon_lease.lease_until", + "turn_daemon_lease.heartbeat_at", + "clock_suspension.cut_wall_at", + "clock_suspension.resume_wall_at", + "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 new file mode 100644 index 00000000..e841da76 --- /dev/null +++ b/docs/architecture/game-clock-reconciliation.md @@ -0,0 +1,183 @@ +# Game clock reconciliation + +## Product contract + +Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000` +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`: + +- `clock_phase` gates gameplay commits. +- `clock_revision` identifies the coordinate conversion generation. +- `deadline_generation` fences worker deadlines rebuilt from that generation. +- `clock_tick` and `clock_wall_anchor` form the durable observed-time snapshot. +- `last_turn_tick` is the execution cursor and is independent from occurrence + history. + +The phases are `PREOPEN`, `RUNNING`, `SUSPENDED`, `RECONCILING`, `MANUAL`, and +`COMPLETED`. `PREOPEN` alone permits signed negative observed ticks and floors +executable schedules at zero. `RUNNING` never projects below its durable tick +when wall time moves backward. `SUSPENDED`, `RECONCILING`, and `COMPLETED` do not +permit turn or monthly commits. `MANUAL` moves only through explicit engine +progression. + +## Durable operation + +A suspension begins under the turn-daemon fence and schema-scoped clock lock. +It records the cut tick, database wall instant, rate, source revision, and +participant checksum in `clock_suspension`. Resume reads the database wall +instant and builds an exact plan: + +```text +gapTicks = max(0, ticksBetween(cutWall, resumeWall, rateAtCut)) +shiftTicks = gapTicks - catchUpTicks +alignedTick = cutTick + gapTicks +deadlineAfter = deadlineBefore + shiftTicks +``` + +Planned maintenance, delayed opening, and unification wait use zero catch-up. +The compatibility-only complete-turn behavior is named +`LEGACY_COMPLETE_TURNS`; it is not the exact policy. + +Every participant writes its `SHIFT`, `KEEP`, `REBUILD`, or `FORBID` decision, +row count, and before/after checksum to `clock_reconciliation_participant`. +The authoritative registry is +[`game-clock-participants.json`](./game-clock-participants.json). The +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 +persists a deterministic `UNIFICATION_WAIT` suspension in the same transaction +as the archive, prompts, and final unification state. Only a `raiseInvader` +message response tied to that active suspension may pass the suspended command +queue; all other gameplay remains pending. + +If the profile processes are operationally stopped during this wait, Gateway +RESUME starts the runtime without consuming the suspension. The database remains +`SUSPENDED`; the daemon-authorized response is still the only transition that may +reconcile it. Selecting one difficulty resolves every pending `raiseInvader` +alternative created at the same game tick, while preserving each message's wall +envelope as history. + +The response transaction verifies daemon authority, performs the exact +alignment, applies all participant shifts, optionally changes the turn rate, +then creates the invader nation, deterministic general IDs/RNG results, first +turns, and the final target-revision outbox. The optional rate change refreshes +the outbox with the final base/rate before commit. DB remains `RECONCILING` +until the daemon projection worker applies Redis and verifies the target +revision/generation. Games without an invader choice move directly to +`COMPLETED`. After an invader game reaches `isUnited=3`, `InvaderEnding` also +changes the clock to `COMPLETED` in the terminal monthly transaction. + +## DB to Redis boundary + +The database transaction leaves the phase `RECONCILING` and creates exactly one +`clock_projection_outbox` row for the target revision. An outbox worker rebuilds +auction and tournament projections and writes +`sammo:{profile}:clock:active-revision` last. Only after checksum verification +may the database transition to `RUNNING` for the same target revision and +deadline generation. + +Workers must compare DB revision, Redis active revision, phase, and deadline +generation before dequeue and again in their final database transaction. Due +pop is one Redis operation: verify revision/phase, read `-inf..nowTick`, and +remove the claimed members. A failed Redis rebuild therefore leaves the game in +`RECONCILING`; process liveness alone is not readiness. + +`applyNextClockProjection()` claims rows with `FOR UPDATE SKIP LOCKED` and uses +PostgreSQL UTC wall time for claim/retry timestamps. One Redis Lua operation +compares the active source revision, rebuilds the auction timer, conditionally +replaces the exact tournament source snapshot, and writes target revision plus +deadline generation. The DB finalizer then re-acquires the clock-operation lock +and changes `RECONCILING -> RUNNING` only when target revision and generation +still match. If the process dies after the Lua commit, retry observes the +already-active target revision and performs only the DB finalizer. + +An active legacy tournament containing only `nextAt`/`bettingCloseAt` is a +fail-closed migration boundary. Reconciliation remains incomplete until its +authoritative `nextTick`/`bettingCloseTick` dual-write exists. + +## Lock order + +All mutation paths use this order: + +```text +turn-daemon fencing row +-> game-clock:operation advisory transaction lock +-> general-access:persistence advisory transaction lock (only if needed) +-> world_state FOR UPDATE +-> participant rows/tables in registry order +-> DB commit +-> Redis outbox projection +``` + +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 + +Both production and direct seeding use the same scenario seeder. It stores +`clock_tick = 0`, `last_turn_tick = 0`, and the scheduled opening as +`clock_wall_anchor`. The metadata names `seededAtWall`, `scheduledOpenAtWall`, +`projectedGameDateAtOpening`, and `calendarStart` separately. Precreated general +turn ticks are calculated from zero and therefore cannot be negative. At the +wall anchor the in-memory phase promotion refuses any PREOPEN clock whose stored +tick is not exactly zero. + +## Compatibility and migration + +This branch begins with dual-read defaults for callers and fixtures built before +the new columns. Database migration backfills manual profiles as `MANUAL`, +future anchored realtime profiles as `PREOPEN`, and other profiles as +`RUNNING`. Existing DateTime columns remain projections while tick columns are +authoritative. + +A row is considered an initialized authoritative clock only when +`clock_base_time`, `clock_tick`, `clock_wall_anchor`, and `last_turn_tick` are +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 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. + +Migration `20260903183000_turn_daemon_lease_utc_wall` expires ephemeral daemon +leases at deployment and installs UTC wall defaults. Migration +`20260903201500_complete_invader_game_clock` repairs legacy `isUnited=3` worlds +left in a running/manual phase and resolves obsolete unchosen invader actions at +the terminal authoritative game tick. + +No active participant remains `FORBID`. Tournament writes carry +tick/revision/generation coordinates and are revision-fenced in Redis. +Unification wait uses the same durable ledger and outbox boundary; the former +temporary `lastTurnTime` save/restore workaround is not part of the workflow. diff --git a/docs/architecture/game-clock.md b/docs/architecture/game-clock.md index 09d57356..320d0dba 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,24 @@ 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`에서는 새 베팅도 받지 않습니다. + +daemon lease는 게임 schedule이 아니라 운영 `WALL_TIME`입니다. 게임 DB session이 +`Asia/Seoul`이어도 acquire/renew/assert/release는 모두 +`CURRENT_TIMESTAMP AT TIME ZONE 'UTC'`를 사용합니다. 따라서 정지한 게임의 +lease도 현실 시간에 만료되며, KST session에서 UTC `timestamp without time zone` +column을 9시간 미래로 쓰지 않습니다. + ## 중단 후 재개 realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용합니다. @@ -45,21 +66,32 @@ realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용 장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행 시각으로 다시 고정합니다. -DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 migration +통일 후 이민족 선택을 기다리는 `UNIFICATION_WAIT`는 일반 maintenance 재개와 +다릅니다. 운영상 STOP된 profile을 RESUME하면 Gateway는 프로세스만 다시 띄우고 +게임 clock은 `SUSPENDED`로 유지합니다. daemon이 수신자 소유권과 command fence를 +확인한 `raiseInvader` 응답만 suspension을 원자적으로 reconciliation할 수 있습니다. +한 난이도를 수락하면 같은 통일 tick의 다른 선택지는 모두 resolved 처리됩니다. +이민족전이 끝나 `isUnited=3`이 되면 clock phase도 같은 월 transaction에서 +`COMPLETED`가 되어 이후 GAME_TIME이 진행하지 않습니다. + +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/runtime.md b/docs/architecture/runtime.md index 6cecd93c..f9ab1a71 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -222,6 +222,11 @@ claim 가능한 상태에서 attempts를 증가시켜 재처리합니다. 5. `EngineStateManager`가 in-memory mutation과 transaction flush를 묶습니다. 6. `TurnDaemonLifecycle`이 control queue와 schedule을 실행합니다. +`turn_daemon_lease`는 운영 WALL_TIME입니다. 모든 write와 active 비교는 DB +session timezone과 무관하게 UTC wall expression을 사용합니다. Lease migration은 +profile process가 멈춘 배포 경계에서 기존 ephemeral row를 만료시켜 구버전 +writer가 만든 KST timestamp도 새 daemon의 fresh fencing epoch를 막지 않습니다. + Lifecycle은 가장 빠른 장수 턴과 다음 tick 중 앞선 시각을 선택합니다. pause gate, 수동 run, shutdown과 budget을 같은 loop에서 처리합니다. @@ -290,6 +295,12 @@ event catalog, in-memory state, dirty marking, flush와 reload 검증까지 - Worker timeout은 요청 실패로 반환하며 DB commit 여부를 별도로 확인합니다. - API·daemon process 재시작은 `InputEvent`, lease, checkpoint와 operation 상태에서 이어집니다. +- `UNIFICATION_WAIT` 중 operational STOP/재시작은 profile process만 복구하며 + suspension을 일반 RESUME으로 소비하지 않습니다. 선택 응답의 daemon fence가 + clock alignment와 이민족 생성을 함께 commit합니다. +- test-only `ManualClock`과 `StepClock`의 sleep은 논리 시간을 즉시 전진한 뒤 + event loop에 한 번 양보합니다. terminal/suspended polling이 timer 기반 + shutdown과 test timeout을 굶기지 않아야 합니다. - 운영 process와 외부 Caddy 상태는 local build·mock E2E로 증명되지 않습니다. ## Build와 배포 diff --git a/docs/architecture/time-domains.md b/docs/architecture/time-domains.md new file mode 100644 index 00000000..5ffea926 --- /dev/null +++ b/docs/architecture/time-domains.md @@ -0,0 +1,175 @@ +# 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 | acquire/renew/assert/release use `CURRENT_TIMESTAMP AT TIME ZONE 'UTC'`, including KST sessions | +| `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. + +Migration `20260903183000_turn_daemon_lease_utc_wall` treats daemon leases as +ephemeral WALL authority: it expires pre-deployment rows, changes the heartbeat +default to DB UTC, and requires the new daemon to acquire a fresh fencing epoch. +Migration `20260903201500_complete_invader_game_clock` moves historical +`isUnited>=3` worlds from `RUNNING`/`MANUAL` to `COMPLETED` and resolves pending +unchosen invader actions at `max(created_game_tick, world_state.clock_tick)`. +Neither migration shifts a WALL occurrence or turns one rule into a clock +fallback. diff --git a/docs/developer/game-clock-reconciliation-plan.md b/docs/developer/game-clock-reconciliation-plan.md new file mode 100644 index 00000000..a56fc38b --- /dev/null +++ b/docs/developer/game-clock-reconciliation-plan.md @@ -0,0 +1,181 @@ +# Game clock reconciliation implementation plan + +Baseline: `main@b91dcbcaaac5acd4c7349cd3ed0996c547f58756` + +Branch: `test/game-clock-reconciliation-20260903` + +This plan is the status source for the long-running user test branch. A checked +item means code and focused automated evidence exist on this branch; it does not +mean deployment or production validation. + +## Milestone 1 - authority and inventory + +- [x] Branded `GameTick`, `ObservedGameInstant`, `ScheduleInstant`, + `WallInstant`, and `ClockRevision` boundaries. +- [x] Explicit clock phase and monotonic RUNNING projection. +- [x] Exact alignment arithmetic preserving millisecond/sub-turn remainder. +- [x] Opening tick zero and PREOPEN executable floor in the shared seeder. +- [x] Schema columns for phase, revision, and deadline generation. +- [x] Suspension, participant-checksum, and Redis projection outbox tables. +- [x] Machine-readable DB/Redis/JSON participant inventory and architecture gate. +- [x] Turn flush lock prefix and phase/revision/generation fence. +- [x] Empty and upgraded database migration execution evidence. + +## Milestone 2 - exact DB reconciliation + +- [x] Suspension start command with DB wall time and idempotent source revision. +- [x] Exact resume plan transaction with deterministic participant lock order. +- [x] SHIFT adapters for cursor, generals, active auctions, message expiry, vote + end, select pool, and NPC selection windows. +- [x] KEEP checksum adapters for occurrences and accepted command coordinates. +- [x] Explicit `LEGACY_COMPLETE_TURNS` and bounded `CATCH_UP` policies. +- [x] Property tests for remaining distance, ordering, and history invariants. +- [x] 24-hour and 65m17.250s PostgreSQL integration evidence. + +## Milestone 3 - revisioned Redis and workers + +- [x] Projection outbox claimer/retry/recovery state machine. +- [x] Redis active revision and atomic due-pop script. +- [x] Auction OPEN/FINALIZING revision and generation fence. +- [x] Tournament durable tick dual-write and projection rebuild. +- [x] DB-commit/Redis-failure crash-restart test. +- [x] Readiness integration in Gateway/API process health. + +## Milestone 4 - command and lifecycle workflows + +- [x] All durable input events record accepted tick and accepted revision. +- [x] Processing converts accepted coordinates across revisions or fails closed. +- [x] Gateway pause/resume/open orchestration writes the DB clock phase. +- [x] Unification wait becomes a durable `UNIFICATION_WAIT` suspension. +- [x] Alignment, optional rate change, invader IDs/RNG, creation, first schedule, + outbox, verification, and RUNNING transition form one retry-safe workflow. +- [x] Multi-host drift and general-access/clock-operation deadlock tests. + +## Milestone 5 - test-branch release gate + +- [x] Full typecheck, architecture, lint, unit, build, and non-conditional + integration suites. +- [x] Dedicated PostgreSQL/Redis conditional integration suite with skip count + recorded. +- [x] Recovery runbook exercised from each incomplete status. +- [x] Admin status/readiness exposes revision, phase, participant checksums, and + incomplete outbox state. +- [ ] User-test deployment evidence is recorded separately from Git push. +- [x] All `FORBID` inventory entries are removed by typed migrations or proven + inactive preconditions. + +## Evidence log + +### 2026-09-03 - authority foundation + +- `pnpm test:bootstrap`: dependency installation, Prisma generation, and package + preparation passed in the dedicated worktree. +- `CI=1 TURBO_CONCURRENCY=1 pnpm typecheck`: 21/21 tasks passed. +- `CI=1 TURBO_CONCURRENCY=1 pnpm test`: 12/12 package tasks passed. Conditional + suites remain classified separately and are not integration evidence. +- `CI=1 TURBO_CONCURRENCY=1 pnpm build`: 26/26 tasks passed. +- `TURBO_CONCURRENCY=1 pnpm lint`: passed with 36 pre-existing frontend + warnings and no errors. +- `pnpm check:architecture`: package boundaries passed; 21 authoritative clock + fields and 18 participants were registered. +- Migration SQL was generated, formatted, validated, and registered as the + release manifest head. All 43 migrations applied to the empty dedicated + PostgreSQL instance. A second schema upgraded from the previous head with an + existing manual `world_state` row and verified `MANUAL:1:1` plus all three + reconciliation tables. +- Dedicated PostgreSQL/Redis fixtures proved exact 24-hour and 65m17.250s gaps, + schedule distance/order preservation, occurrence checksums, live-lease + fencing, a single durable outbox, Redis target revision, and recovery after a + crash between Redis commit and DB finalization. + +### 2026-09-03 - revisioned workers and input coordinates + +- `CI=1 TURBO_CONCURRENCY=1 pnpm typecheck`: 21/21 tasks passed after the + worker/input contract changes. +- `CI=1 TURBO_CONCURRENCY=1 pnpm test`: 12/12 package tasks passed. +- Dedicated PostgreSQL runs passed `databaseCommandQueue.integration.test.ts` + 6/6 and `runtimeClockShiftPersistence.integration.test.ts` 3/3 when executed + sequentially; the latter now clears each single-world fixture before the next + case. Dedicated Redis passed tournament source revision 4/4, including an + atomic stale-clock rejection. API input-event integration passed 13/13. +- The 24-hour/65m17.250s reconciliation suite passed 2/2 after the other DB + suites. Conditional files share a deliberately dedicated schema and are run + sequentially to prevent their fixture cleanup from racing another file. + +### 2026-09-03 - Gateway lifecycle authority + +- Runtime `PAUSE`/`STOP` starts a durable maintenance suspension before the + Gateway status and process reconciliation change. `RESUME` completes the DB + reconciliation and Redis outbox before the profile becomes `RUNNING`. +- Both an already-built overdue `RESERVED` profile and an overdue `PREOPEN` + profile promote the game DB from `PREOPEN@0` before the Gateway status changes + to `RUNNING`; the Redis clock phase is revision/generation fenced. +- `pnpm --filter @sammo-ts/gateway-api test` passed 313 tests with 35 + environment-conditional skips. Gateway typecheck and target lint passed. + +### 2026-09-03 - atomic unification wait + +- A unification flush now commits the finalization, actionable prompts, and one + deterministic `UNIFICATION_WAIT` suspension together. A late archive failure + rolls the whole boundary back; retry creates one ledger. +- The suspended command queue admits only an invader decision tied to the + active ledger. The daemon-authorized command transaction applies a 36-hour + exact gap, preserves participant positions, changes the fixture rate from 10 + to 20 minutes, creates one invader nation and ten deterministic generals with + future first turns, and writes one final-rate projection outbox. +- The dedicated PostgreSQL/Redis fixture reached `RUNNING@2/2` only after the + Redis projection. DB-wall versus a mocked 12-hour host drift and concurrent + general-access lock acquisition completed without drift or deadlock. +- `CI=1 TURBO_CONCURRENCY=1 pnpm typecheck` passed 21/21 tasks, + `CI=1 TURBO_CONCURRENCY=1 pnpm test` passed 12/12 package tasks, + `CI=1 TURBO_CONCURRENCY=1 pnpm build` passed 26/26 tasks, and workspace lint + passed. `pnpm check:architecture` registered 22 authoritative clock fields + and 18 participants. +- Dedicated sequential PostgreSQL/Redis runs passed clock reconciliation 3/3, + atomic unification 1/1, command queue 7/7, runtime clock persistence 3/3, + API input-event boundary 13/13, and tournament revision 4/4: 31/31 enabled + tests with zero skips. The queue and API suites now create and remove their + own clock fixtures so a completed file cannot leak phase or initialization + state into the next file. +- User-test deployment and public runtime evidence remain deliberately open: + Git push is not deployment, and no deployment was authorized in this work. + +### 2026-09-03 - completion audit + +- The invader response now reads the exact post-alignment world snapshot even + when the turn rate is unchanged. The HWE-shaped regression asserts all ten + first turns are in the aligned next-month window rather than already due. +- The ordinary flush fence now uses the same incomplete-row dual-read rule as + `worldLoader`: a legacy row is `MANUAL` until all four clock snapshot fields + exist. Input-event acceptance remains fail-closed until initialization. +- Auction OPEN/FINALIZING recovery fixtures and direct PROCESSING input-event + fixtures now carry explicit phase/revision/generation coordinates. The + optional Ref-only troop parity suite is registered only in Ref mode, so the + Core conditional gate reports only tests it actually ran. +- `pnpm check:architecture` passed with 22 authoritative fields and all 18 + required DB/JSON participants. The gate now rejects duplicate or `FORBID` + participants, missing required participants, and unimplemented/duplicate + Redis participants. +- `CI=1 TURBO_CONCURRENCY=1 pnpm test:integration:conditional` passed 79 files + and 234 tests against isolated PostgreSQL/Redis schemas with zero skipped and + zero failed files. +- On the audited diff, `pnpm check:architecture` passed, full typecheck passed + 21/21 tasks, workspace test passed 12/12 tasks, build passed 26/26 tasks, and + workspace lint completed without errors. + +The required acceptance cases map to automated evidence as follows: + +| Contract | Automated evidence | +| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| PREOPEN signed tick, tick-zero opening, executable floor; RUNNING rewind monotonicity | `packages/common/test/gameClock.test.ts`, `app/game-engine/test/scenarioSeeder.test.ts`, `app/gateway-api/test/orchestratorOperations.test.ts` | +| Exact 24-hour and 65m17.250s gaps; ordering, remaining distance, occurrence history | `app/game-engine/test/clockReconciliation.integration.test.ts`, `packages/common/test/gameClock.test.ts` | +| DB commit/Redis failure and incomplete-status recovery | `app/game-engine/test/clockReconciliation.integration.test.ts`, `app/game-engine/test/unificationFinalization.integration.test.ts` | +| Auction OPEN/FINALIZING and tournament revision races | `app/game-api/test/auctionWorker.integration.test.ts`, `app/game-api/test/tournamentStoreRevision.integration.test.ts` | +| Pending commands crossing a clock revision | `app/game-engine/test/databaseCommandQueue.integration.test.ts`, `app/game-api/test/inputEventBoundary.integration.test.ts` | +| Delayed opening | `app/gateway-api/test/orchestratorOperations.test.ts`, `app/game-engine/test/scenarioSeeder.test.ts` | +| 36-hour unification wait, rate change, deterministic retry, future invader turns | `app/game-engine/test/unificationFinalization.integration.test.ts`, `app/game-engine/test/unificationInvaderResume.test.ts` | +| DB wall despite host drift; general-access lock ordering | `app/game-engine/test/clockReconciliation.integration.test.ts`, `app/game-engine/test/profileSchemaAdvisoryLock.integration.test.ts` | +| Generated exact-gap ordering/remaining/history invariants | `packages/common/test/gameClock.test.ts` | + +Deployment and public-runtime evidence remain outside this audit and are still +open pending explicit user-test deployment authorization. diff --git a/docs/developer/game-clock-recovery.md b/docs/developer/game-clock-recovery.md new file mode 100644 index 00000000..6ad668dd --- /dev/null +++ b/docs/developer/game-clock-recovery.md @@ -0,0 +1,61 @@ +# Game clock reconciliation recovery + +This runbook is intentionally fail-closed. Do not force a profile to `RUNNING` +or delete an outbox row merely because its process is alive. + +## Observe + +Read only the target game schema. Record `world_state.clock_phase`, +`clock_revision`, `deadline_generation`, the latest `clock_suspension`, all its +participant checksums, and the matching `clock_projection_outbox`. Compare that +target revision with `sammo:{profile}:clock:active-revision`. Never print DB or +Redis credentials. + +## Status meaning + +- `SUSPENDED`: the cut is durable; no alignment DB transaction has committed. +- `RECONCILING` with `PENDING`/`FAILED` outbox: DB schedules moved, Redis is not + authoritative yet, and gameplay must remain stopped. +- `RECONCILING` with `APPLIED` outbox: verify Redis active revision and all + participant checksums before finalizing. +- `RUNNING`: DB revision, deadline generation, and Redis active revision must + agree. A mismatch is an incident and workers must not dequeue. + +## Retry + +Retry the same suspension ID and target revision through the clock-operation +service. The service must re-read participant checksums and either return the +already-applied result or resume the pending outbox. Never create a replacement +revision to hide a failed target revision. + +For `UNIFICATION_WAIT`, never rerun invader creation as a separate repair. +The input event, aligned schedules, optional rate, deterministic invader IDs, +reserved turns, and outbox committed together. A committed command with a +`RECONCILING` world therefore needs only the same outbox retry. If the command +transaction rolled back, the original prompt and source revision remain and +the same response can be retried without changing IDs or RNG results. + +When an outbox row is `FAILED`, the profile must remain `RECONCILING`. A retry +is safe in both crash locations: + +- before Redis commit, the Lua operation reapplies from the source revision; +- after Redis commit but before DB finalization, the Lua operation returns the + already-active target result and DB finalization resumes without shifting any + tournament deadline twice. + +`lastError` naming a legacy tournament deadline means the tournament lacks its +tick dual-write. Do not force the active revision; migrate or prove the +tournament inactive, then retry the same outbox. + +## Rollback + +There is no blind inverse update. Before enabling exact reconciliation in an +environment, keep the normal database backup required for schema migrations. +If participant verification shows an unexpected mutation, stop the profile, +retain the ledger/outbox evidence, and restore the whole game schema from that +backup. Redis projections are then rebuilt from the restored DB revision. + +The conditional clock suite exercises `SUSPENDED`, `RECONCILING/PENDING`, +`RECONCILING/FAILED` before and after Redis commit, recovered `APPLIED`, and +final `RUNNING`. The admin status endpoint exposes the phase, revision, +participant checksums, and outbox error needed to choose the matching step. diff --git a/package.json b/package.json index 54611a3b..de9a7e98 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check", "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check", "check:legacy:scenario": "SAMMO_REQUIRE_REF_SOURCE=1 pnpm --filter @sammo-ts/game-engine test monthlyCatalogCoverage.test.ts scenarioLoader.test.ts scenarioComposition.test.ts", - "check:architecture": "node tools/check-package-boundaries.mjs", + "check:architecture": "node tools/check-package-boundaries.mjs && node tools/check-game-clock-participants.mjs", "check:typescript-toolchain": "node tools/check-typescript-toolchain.mjs", "test:architecture": "node --test tools/check-package-boundaries.test.mjs", "test:image-sync": "node --test tools/sync-image-repository.test.mjs", diff --git a/packages/common/src/time/Clock.ts b/packages/common/src/time/Clock.ts index d285bab0..7f6abb2b 100644 --- a/packages/common/src/time/Clock.ts +++ b/packages/common/src/time/Clock.ts @@ -34,6 +34,7 @@ export class ManualClock implements Clock { return; } this.currentMs += ms; + await new Promise((resolve) => setTimeout(resolve, 0)); } advanceMs(ms: number): void { @@ -71,6 +72,7 @@ export class StepClock implements Clock { return; } this.currentMs += ms; + await new Promise((resolve) => setTimeout(resolve, 0)); } advanceMs(ms: number): void { diff --git a/packages/common/src/time/GameClock.ts b/packages/common/src/time/GameClock.ts index e6f98415..fa068be2 100644 --- a/packages/common/src/time/GameClock.ts +++ b/packages/common/src/time/GameClock.ts @@ -2,6 +2,36 @@ export const GAME_TICKS_PER_TURN = 36_000_000; export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER; export type GameClockMode = 'realtime' | 'manual'; +export type GameClockPhase = 'PREOPEN' | 'RUNNING' | 'SUSPENDED' | 'RECONCILING' | 'MANUAL' | 'COMPLETED'; +export type ClockAlignmentPolicy = 'EXACT' | 'LEGACY_COMPLETE_TURNS' | 'CATCH_UP'; + +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; + sourceRevision: ClockRevision; + targetRevision: ClockRevision; + cutTick: GameTick; + gapTicks: GameTick; + catchUpTicks: GameTick; + shiftTicks: GameTick; + alignedTick: GameTick; +} export interface GameClockState { baseTime: Date; @@ -9,6 +39,8 @@ export interface GameClockState { mode: GameClockMode; wallAnchor: Date; turnSeconds: number; + phase?: GameClockPhase; + revision?: number; } const requireSafeTick = (tick: number): number => { @@ -18,6 +50,162 @@ const requireSafeTick = (tick: number): number => { return tick; }; +export const asGameTick = (tick: number): GameTick => requireSafeTick(tick) as GameTick; + +export const asObservedGameInstant = (tick: number): ObservedGameInstant => + requireSafeTick(tick) as ObservedGameInstant; + +export const asScheduleInstant = (tick: number): ScheduleInstant => requireSafeTick(tick) as ScheduleInstant; + +export const asClockRevision = (revision: number): ClockRevision => { + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error(`Clock revision must be a positive safe integer: ${revision}`); + } + 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.'); + } + 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[] = [ + 'PREOPEN', + 'RUNNING', + 'SUSPENDED', + 'RECONCILING', + 'MANUAL', + 'COMPLETED', +]; + +export const parseGameClockPhase = (value: string): GameClockPhase => { + if ((GAME_CLOCK_PHASES as readonly string[]).includes(value)) { + return value as GameClockPhase; + } + throw new Error(`Unknown game clock phase: ${value}`); +}; + +const CLOCK_ALIGNMENT_POLICIES: readonly ClockAlignmentPolicy[] = ['EXACT', 'LEGACY_COMPLETE_TURNS', 'CATCH_UP']; + +export const parseClockAlignmentPolicy = (value: string): ClockAlignmentPolicy => { + if ((CLOCK_ALIGNMENT_POLICIES as readonly string[]).includes(value)) { + return value as ClockAlignmentPolicy; + } + throw new Error(`Unknown clock alignment policy: ${value}`); +}; + +export const scheduleNotBefore = (instant: ObservedGameInstant, phase: GameClockPhase): ScheduleInstant => { + if (phase === 'PREOPEN') { + return asScheduleInstant(Math.max(0, instant)); + } + return asScheduleInstant(instant); +}; + +export const createDeadline = ( + instant: ObservedGameInstant, + durationTicks: GameTick, + phase: GameClockPhase +): ScheduleInstant => scheduleNotBefore(asObservedGameInstant(requireSafeTick(instant + durationTicks)), phase); + +export const assertGameplayCommitAllowed = (phase: GameClockPhase): void => { + if (phase !== 'RUNNING' && phase !== 'MANUAL') { + throw new Error(`Gameplay commit is forbidden while the game clock phase is ${phase}.`); + } +}; + +const buildAlignmentPlan = (input: { + policy: ClockAlignmentPolicy; + sourceRevision: number; + cutTick: number; + cutWall: Date; + resumeWall: Date; + ticksPerSecond: number; + catchUpTicks?: number; +}): ClockAlignmentPlan => { + const sourceRevision = asClockRevision(input.sourceRevision); + const cutTick = asGameTick(input.cutTick); + const cutWall = asWallInstant(input.cutWall); + const resumeWall = asWallInstant(input.resumeWall); + if (!Number.isSafeInteger(input.ticksPerSecond) || input.ticksPerSecond <= 0) { + throw new Error(`ticksPerSecond must be a positive safe integer: ${input.ticksPerSecond}`); + } + const elapsedMilliseconds = Math.max(0, resumeWall.getTime() - cutWall.getTime()); + if (!Number.isSafeInteger(elapsedMilliseconds)) { + throw new Error('Clock suspension wall gap is outside the safe integer range.'); + } + const wholeSeconds = Math.trunc(elapsedMilliseconds / 1_000); + const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000; + const gapTicks = asGameTick( + requireSafeTick( + wholeSeconds * input.ticksPerSecond + Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000) + ) + ); + const catchUpTicks = asGameTick(input.catchUpTicks ?? 0); + if (catchUpTicks < 0 || catchUpTicks > gapTicks) { + throw new Error(`catchUpTicks must be between 0 and the wall gap (${gapTicks}): ${catchUpTicks}`); + } + const shiftTicks = asGameTick(gapTicks - catchUpTicks); + return { + policy: input.policy, + sourceRevision, + targetRevision: asClockRevision(sourceRevision + 1), + cutTick, + gapTicks, + catchUpTicks, + shiftTicks, + alignedTick: asGameTick(cutTick + gapTicks), + }; +}; + +export const buildExactClockAlignmentPlan = ( + input: Omit[0], 'policy'> +): ClockAlignmentPlan => buildAlignmentPlan({ ...input, policy: 'EXACT' }); + +export const buildClockAlignmentPlan = (input: { + policy: ClockAlignmentPolicy; + sourceRevision: number; + cutTick: number; + cutWall: Date; + resumeWall: Date; + ticksPerSecond: number; + catchUpTicks?: number; +}): ClockAlignmentPlan => { + if (input.policy === 'EXACT') { + if ((input.catchUpTicks ?? 0) !== 0) { + throw new Error('EXACT alignment does not allow catch-up ticks.'); + } + return buildAlignmentPlan({ ...input, policy: 'EXACT', catchUpTicks: 0 }); + } + if (input.policy === 'CATCH_UP') { + return buildAlignmentPlan({ ...input, policy: 'CATCH_UP' }); + } + const exact = buildAlignmentPlan({ ...input, policy: 'LEGACY_COMPLETE_TURNS', catchUpTicks: 0 }); + const shiftTicks = asGameTick(Math.floor(exact.gapTicks / GAME_TICKS_PER_TURN) * GAME_TICKS_PER_TURN); + return { + ...exact, + catchUpTicks: asGameTick(exact.gapTicks - shiftTicks), + shiftTicks, + }; +}; + const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => { const wholeSeconds = Math.floor(tick / ticksPerSecond); const remainingTicks = tick - wholeSeconds * ticksPerSecond; @@ -35,6 +223,8 @@ export class GameClock { readonly wallAnchor: Date; readonly turnSeconds: number; readonly ticksPerSecond: number; + readonly phase: GameClockPhase; + readonly revision: ClockRevision; constructor(state: GameClockState) { if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) { @@ -55,6 +245,8 @@ export class GameClock { this.wallAnchor = new Date(state.wallAnchor.getTime()); this.turnSeconds = state.turnSeconds; this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds; + this.phase = state.phase ?? inferClockPhase(state.mode); + this.revision = asClockRevision(state.revision ?? 1); } static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date { @@ -76,14 +268,25 @@ export class GameClock { } nowTick(wallNow: Date): number { - if (this.mode === 'manual') { + 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); // A future realtime anchor represents the formal opening at anchor tick. // Before that instant Ref exposes the elapsed offset as a negative tick, // which lets PREOPEN-only actions keep their logical cooldowns moving. - return this.addTicks(this.tick, elapsedTicks); + const projectedTick = this.addTicks(this.tick, elapsedTicks); + // PREOPEN is the only phase where the opening anchor may project a + // signed negative coordinate. Once a realtime game is running, an NTP + // rewind must never make the observed coordinate decrease below the + // last durable clock snapshot. + return this.phase === 'PREOPEN' ? projectedTick : Math.max(this.tick, projectedTick); } now(wallNow: Date): Date { diff --git a/packages/common/src/tournament/sourceRevision.ts b/packages/common/src/tournament/sourceRevision.ts index 29cc6b83..8900edc5 100644 --- a/packages/common/src/tournament/sourceRevision.ts +++ b/packages/common/src/tournament/sourceRevision.ts @@ -18,6 +18,15 @@ export interface TournamentProjectionWrite { value: unknown; } +export interface TournamentClockFence { + activeRevisionKey: string; + deadlineGenerationKey: string; + phaseKey: string; + revision: number; + deadlineGeneration: number; + phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED'; +} + const WRITE_TOURNAMENT_PROJECTION_SCRIPT = ` local revision_key = KEYS[#KEYS] local current = redis.call('GET', revision_key) @@ -55,6 +64,49 @@ local revision = redis.call('INCR', revision_key) return tostring(revision) .. ':' .. (stage_changed and '1' or '0') .. ':' .. (rankings_changed and '1' or '0') `; +const WRITE_FENCED_TOURNAMENT_PROJECTION_SCRIPT = ` +local revision_key_index = #KEYS - 3 +if redis.call('GET', KEYS[#KEYS - 2]) ~= ARGV[#ARGV - 2] + or redis.call('GET', KEYS[#KEYS - 1]) ~= ARGV[#ARGV - 1] + or redis.call('GET', KEYS[#KEYS]) ~= ARGV[#ARGV] then + return '__CLOCK_FENCE__' +end +local revision_key = KEYS[revision_key_index] +local current = redis.call('GET', revision_key) +if current then + if not string.match(current, '^%d+$') then + return redis.error_reply('invalid tournament source revision') + end + if string.len(current) > 18 then + return redis.error_reply('tournament source revision exhausted') + end +end +local stage_changed = false +local rankings_changed = false +for index = 1, revision_key_index - 1 do + local next_ok, next_value = pcall(cjson.decode, ARGV[index]) + if next_ok and type(next_value) == 'table' and next_value['stage'] ~= nil then + local previous = redis.call('GET', KEYS[index]) + local previous_stage = nil + local previous_value = nil + if previous then + local previous_ok + previous_ok, previous_value = pcall(cjson.decode, previous) + if previous_ok and type(previous_value) == 'table' then + previous_stage = previous_value['stage'] + end + end + local next_stage = next_value['stage'] + stage_changed = (not previous) or previous_stage ~= next_stage + local previous_reward_settled = previous_value and previous_value['rewardSettled'] or false + rankings_changed = next_value['rewardSettled'] == true and previous_reward_settled ~= true + end + redis.call('SET', KEYS[index], ARGV[index]) +end +local revision = redis.call('INCR', revision_key) +return tostring(revision) .. ':' .. (stage_changed and '1' or '0') .. ':' .. (rankings_changed and '1' or '0') +`; + export const parseTournamentSourceRevision = (value: unknown): string | null => { if (typeof value === 'number') { return Number.isSafeInteger(value) && value >= 0 ? String(value) : null; @@ -69,7 +121,8 @@ export const parseTournamentSourceRevision = (value: unknown): string | null => export const writeTournamentProjection = async ( redis: TournamentProjectionRedis, keys: TournamentSourceKeys, - writes: readonly TournamentProjectionWrite[] + writes: readonly TournamentProjectionWrite[], + clockFence?: TournamentClockFence ): Promise => { if (writes.length === 0) { throw new Error('Tournament projection write must contain at least one payload.'); @@ -90,10 +143,27 @@ export const writeTournamentProjection = async ( value !== null && (value as { rewardSettled?: unknown }).rewardSettled === true ); - const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, { - keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey], - arguments: writes.map(({ value }) => JSON.stringify(value)), - }); + const result = await redis.eval( + clockFence ? WRITE_FENCED_TOURNAMENT_PROJECTION_SCRIPT : WRITE_TOURNAMENT_PROJECTION_SCRIPT, + { + keys: [ + ...writes.map(({ key }) => key), + keys.sourceRevisionKey, + ...(clockFence + ? [clockFence.activeRevisionKey, clockFence.deadlineGenerationKey, clockFence.phaseKey] + : []), + ], + arguments: [ + ...writes.map(({ value }) => JSON.stringify(value)), + ...(clockFence + ? [String(clockFence.revision), String(clockFence.deadlineGeneration), clockFence.phase] + : []), + ], + } + ); + if (result === '__CLOCK_FENCE__') { + throw new Error('Tournament projection clock revision fence failed.'); + } const scriptResult = typeof result === 'string' ? /^(\d+):([01])(?::([01]))?$/u.exec(result) : null; const sourceRevision = parseTournamentSourceRevision(scriptResult?.[1] ?? result); // Plain revision results remain accepted for rolling deployments and small diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index a8fb7ae5..513a6c05 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -161,6 +161,15 @@ export type TurnDaemonCommand = messageId: number; response: boolean; } + | { + type: 'syncDiplomaticResponse'; + requestId?: string; + userId: string; + generalId: number; + messageId: number; + nationIds: number[]; + cityIds: number[]; + } | { type: 'vacation'; requestId?: string; userId: string; generalId: number } | { type: 'setMySetting'; @@ -190,7 +199,7 @@ export type TurnDaemonCommand = requestId?: string; auctionId: number; expectedCloseAt?: string; - expectedCloseTick?: number; + expectedCloseTick: number; } | { type: 'auctionOpen'; @@ -260,7 +269,6 @@ export type TurnDaemonCommand = voteId: number; generalId: number; selection: number[]; - acceptedGameTick?: number; } | { type: 'setNationSetting'; @@ -386,15 +394,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 +412,6 @@ export type TurnDaemonCommand = ownerPicture?: string; ownerImageServer?: number; ownerIconRevision?: string; - acceptedGameAt?: string; - acceptedGameTick?: number; } | { type: 'selectPoolReselect'; @@ -416,8 +419,6 @@ export type TurnDaemonCommand = userId: string; ownerDisplayName: string; uniqueName: string; - acceptedGameAt?: string; - acceptedGameTick?: number; } | { type: 'auctionBid'; @@ -426,7 +427,6 @@ export type TurnDaemonCommand = auctionId: number; generalId: number; amount: number; - acceptedGameTick?: number; tryExtendCloseDate?: boolean; }; @@ -505,6 +505,7 @@ export type TurnDaemonCommandResult = ok: true; auctionId: number; closeAt: string; + closeTick: number; } | { type: 'auctionOpen'; @@ -583,6 +584,16 @@ export type TurnDaemonCommandResult = action?: 'scout' | 'raiseInvader'; reason: string; } + | { + type: 'syncDiplomaticResponse'; + ok: boolean; + generalId: number; + messageId: number; + nations: number; + diplomacy: number; + cities: number; + reason?: string; + } | { type: 'vacation'; ok: boolean; generalId: number; reason?: string } | { type: 'setMySetting'; ok: boolean; generalId: number; reason?: string } | { type: 'dropItem'; ok: boolean; generalId: number; reason?: string } @@ -835,6 +846,7 @@ export type TurnDaemonCommandResult = ok: true; auctionId: number; closeAt: string; + closeTick: number; } | { type: 'auctionBid'; diff --git a/packages/common/test/clock.test.ts b/packages/common/test/clock.test.ts index 7a7a8c59..047a8597 100644 --- a/packages/common/test/clock.test.ts +++ b/packages/common/test/clock.test.ts @@ -11,8 +11,13 @@ describe('ManualClock', () => { it('advances with sleep and manual advance', async () => { const clock = new ManualClock(0); + let eventLoopTurnObserved = false; + setTimeout(() => { + eventLoopTurnObserved = true; + }, 0); await clock.sleepMs(250); expect(clock.nowMs()).toBe(250); + expect(eventLoopTurnObserved).toBe(true); clock.advanceMs(750); expect(clock.nowMs()).toBe(1000); }); @@ -34,8 +39,13 @@ describe('StepClock', () => { it('advances with sleep', async () => { const clock = new StepClock(50, 1000); + let eventLoopTurnObserved = false; + setTimeout(() => { + eventLoopTurnObserved = true; + }, 0); await clock.sleepMs(200); expect(clock.nowMs()).toBe(1250); + expect(eventLoopTurnObserved).toBe(true); }); it('advances manually', () => { diff --git a/packages/common/test/gameClock.test.ts b/packages/common/test/gameClock.test.ts index 8d410955..cd6ae15a 100644 --- a/packages/common/test/gameClock.test.ts +++ b/packages/common/test/gameClock.test.ts @@ -1,8 +1,36 @@ import { describe, expect, it } from 'vitest'; -import { GAME_TICKS_PER_TURN, GameClock, MAX_SAFE_GAME_TICK } from '../src/time/GameClock.js'; +import { + GAME_TICKS_PER_TURN, + GameClock, + MAX_SAFE_GAME_TICK, + asGameTick, + asObservedGameInstant, + buildClockAlignmentPlan, + buildExactClockAlignmentPlan, + createDeadline, + scheduleNotBefore, +} 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', () => { @@ -42,12 +70,126 @@ describe('GameClock', () => { mode: 'realtime', wallAnchor: new Date('2026-01-01T01:00:00.000Z'), turnSeconds: 3_600, + phase: 'PREOPEN', }); expect(clock.nowTick(new Date('2026-01-01T00:30:00.000Z'))).toBe(-GAME_TICKS_PER_TURN / 2); expect(clock.nowTick(new Date('2026-01-01T01:00:00.000Z'))).toBe(0); }); + it('does not rewind a RUNNING realtime tick when wall time moves backward', () => { + const clock = new GameClock({ + baseTime, + tick: GAME_TICKS_PER_TURN, + mode: 'realtime', + wallAnchor: new Date('2026-01-01T01:00:00.000Z'), + turnSeconds: 3_600, + phase: 'RUNNING', + revision: 7, + }); + + expect(clock.nowTick(new Date('2026-01-01T00:59:55.000Z'))).toBe(GAME_TICKS_PER_TURN); + expect(clock.revision).toBe(7); + }); + + it('floors PREOPEN executable schedules at opening tick zero', () => { + const observed = asObservedGameInstant(-GAME_TICKS_PER_TURN / 2); + + expect(scheduleNotBefore(observed, 'PREOPEN')).toBe(0); + expect(createDeadline(observed, asGameTick(GAME_TICKS_PER_TURN / 4), 'PREOPEN')).toBe(0); + expect(scheduleNotBefore(observed, 'RUNNING')).toBe(observed); + }); + + it('preserves a 65 minute 17.250 second sub-turn suspension remainder exactly', () => { + const plan = buildExactClockAlignmentPlan({ + sourceRevision: 11, + cutTick: 123_456, + cutWall: new Date('2026-01-01T00:00:00.000Z'), + resumeWall: new Date('2026-01-01T01:05:17.250Z'), + ticksPerSecond: 10_000, + }); + + expect(plan).toMatchObject({ + sourceRevision: 11, + targetRevision: 12, + gapTicks: 39_172_500, + shiftTicks: 39_172_500, + alignedTick: 39_295_956, + }); + expect(plan.shiftTicks % GAME_TICKS_PER_TURN).toBe(3_172_500); + }); + + it('aligns a 24 hour exact maintenance without catch-up', () => { + const plan = buildExactClockAlignmentPlan({ + sourceRevision: 1, + cutTick: 2 * GAME_TICKS_PER_TURN, + cutWall: new Date('2026-01-01T00:00:00.000Z'), + resumeWall: new Date('2026-01-02T00:00:00.000Z'), + ticksPerSecond: 10_000, + }); + + expect(plan.gapTicks).toBe(24 * GAME_TICKS_PER_TURN); + expect(plan.alignedTick).toBe(26 * GAME_TICKS_PER_TURN); + }); + + it('keeps legacy complete-turn rebasing separate from bounded catch-up', () => { + const common = { + sourceRevision: 4, + cutTick: 100, + cutWall: new Date('2026-01-01T00:00:00.000Z'), + resumeWall: new Date('2026-01-01T01:05:17.250Z'), + ticksPerSecond: 10_000, + }; + const legacy = buildClockAlignmentPlan({ ...common, policy: 'LEGACY_COMPLETE_TURNS' }); + const catchUp = buildClockAlignmentPlan({ ...common, policy: 'CATCH_UP', catchUpTicks: 1_000_000 }); + + expect(legacy).toMatchObject({ + policy: 'LEGACY_COMPLETE_TURNS', + gapTicks: 39_172_500, + shiftTicks: GAME_TICKS_PER_TURN, + catchUpTicks: 3_172_500, + }); + expect(catchUp).toMatchObject({ + policy: 'CATCH_UP', + gapTicks: 39_172_500, + shiftTicks: 38_172_500, + catchUpTicks: 1_000_000, + }); + expect(() => buildClockAlignmentPlan({ ...common, policy: 'EXACT', catchUpTicks: 1 })).toThrow( + 'EXACT alignment does not allow catch-up ticks' + ); + }); + + it('preserves schedule ordering, remaining distance, and occurrence ticks across generated exact gaps', () => { + let seed = 0x5eed1234; + const next = (): number => { + seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; + return seed; + }; + for (let iteration = 0; iteration < 500; iteration += 1) { + const cutTick = next() % 1_000_000_000; + const gapMilliseconds = next() % (7 * 24 * 60 * 60 * 1_000); + const ticksPerSecond = [5_000, 10_000, 60_000][next() % 3]!; + const offsets = Array.from({ length: 8 }, () => next() % (3 * GAME_TICKS_PER_TURN)).sort( + (left, right) => left - right + ); + const occurrenceTicks = Array.from({ length: 4 }, () => cutTick - (next() % GAME_TICKS_PER_TURN)); + const plan = buildClockAlignmentPlan({ + policy: 'EXACT', + sourceRevision: 1 + (next() % 10_000), + cutTick, + cutWall: new Date(0), + resumeWall: new Date(gapMilliseconds), + ticksPerSecond, + }); + const shifted = offsets.map((offset) => cutTick + offset + plan.shiftTicks); + + expect(shifted.map((deadline) => deadline - plan.alignedTick)).toEqual(offsets); + expect([...shifted].sort((left, right) => left - right)).toEqual(shifted); + expect(occurrenceTicks).toEqual([...occurrenceTicks]); + } + }); + it('projects near the safe tick boundary without unsafe intermediate multiplication', () => { const clock = new GameClock({ baseTime: new Date(0), 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 4a71cdd0..748fe4db 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -63,21 +63,27 @@ enum InputEventTarget { } model InputEvent { - sequence BigInt @id @default(autoincrement()) - requestId String @unique @map("request_id") - target InputEventTarget - eventType String @map("event_type") - payload Json @default(dbgenerated("'{}'::jsonb")) - actorUserId String? @map("actor_user_id") - status InputEventStatus @default(PENDING) - result Json? - error String? - attempts Int @default(0) - lockedBy String? @map("locked_by") - leaseUntil DateTime? @map("lease_until") - createdAt DateTime @default(now()) @map("created_at") - processingAt DateTime? @map("processing_at") - completedAt DateTime? @map("completed_at") + sequence BigInt @id @default(autoincrement()) + requestId String @unique @map("request_id") + target InputEventTarget + eventType String @map("event_type") + payload Json @default(dbgenerated("'{}'::jsonb")) + actorUserId String? @map("actor_user_id") + acceptedGameTick BigInt? @map("accepted_game_tick") + acceptedClockRevision BigInt? @map("accepted_clock_revision") + acceptedDeadlineGeneration BigInt? @map("accepted_deadline_generation") + processingGameTick BigInt? @map("processing_game_tick") + processingClockRevision BigInt? @map("processing_clock_revision") + processingDeadlineGeneration BigInt? @map("processing_deadline_generation") + status InputEventStatus @default(PENDING) + result Json? + error String? + attempts Int @default(0) + lockedBy String? @map("locked_by") + leaseUntil DateTime? @map("lease_until") + createdAt DateTime @default(now()) @map("created_at") + processingAt DateTime? @map("processing_at") + completedAt DateTime? @map("completed_at") @@index([target, status, sequence]) @@map("input_event") @@ -145,25 +151,101 @@ model TurnDaemonLease { } model WorldState { - id Int @id @default(autoincrement()) - scenarioCode String @map("scenario_code") - currentYear Int @map("current_year") - currentMonth Int @map("current_month") - tickSeconds Int @map("tick_seconds") - clockBaseTime DateTime? @map("clock_base_time") - clockTick BigInt? @map("clock_tick") - clockMode String @default("realtime") @map("clock_mode") - clockWallAnchor DateTime? @map("clock_wall_anchor") - lastTurnTick BigInt? @map("last_turn_tick") - config Json @default(dbgenerated("'{}'::jsonb")) - meta Json @default(dbgenerated("'{}'::jsonb")) - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + scenarioCode String @map("scenario_code") + currentYear Int @map("current_year") + currentMonth Int @map("current_month") + tickSeconds Int @map("tick_seconds") + clockBaseTime DateTime? @map("clock_base_time") + clockTick BigInt? @map("clock_tick") + clockMode String @default("realtime") @map("clock_mode") + clockWallAnchor DateTime? @map("clock_wall_anchor") + lastTurnTick BigInt? @map("last_turn_tick") + clockPhase String @default("RUNNING") @map("clock_phase") + clockRevision BigInt @default(1) @map("clock_revision") + deadlineGeneration BigInt @default(1) @map("deadline_generation") + config Json @default(dbgenerated("'{}'::jsonb")) + meta Json @default(dbgenerated("'{}'::jsonb")) + updatedAt DateTime @updatedAt @map("updated_at") - trafficPeriods TrafficPeriod[] + trafficPeriods TrafficPeriod[] + clockSuspensions ClockSuspension[] + clockProjectionOutbox ClockProjectionOutbox[] @@map("world_state") } +model ClockSuspension { + id String @id @db.VarChar(64) + worldStateId Int @map("world_state_id") + source String + policy String + status String @default("SUSPENDED") + sourceRevision BigInt @map("source_revision") + targetRevision BigInt @map("target_revision") + cutTick BigInt @map("cut_tick") + cutWallAt DateTime @map("cut_wall_at") @db.Timestamp(3) + resumeWallAt DateTime? @map("resume_wall_at") @db.Timestamp(3) + rateTicksPerSecond Int @map("rate_ticks_per_second") + catchUpTicks BigInt @default(0) @map("catch_up_ticks") + gapTicks BigInt? @map("gap_ticks") + shiftTicks BigInt? @map("shift_ticks") + alignedTick BigInt? @map("aligned_tick") + participantChecksumBefore String? @map("participant_checksum_before") + participantChecksumAfter String? @map("participant_checksum_after") + detail Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3) + + worldState WorldState @relation(fields: [worldStateId], references: [id], onDelete: Cascade) + participants ClockReconciliationParticipant[] + projectionOutbox ClockProjectionOutbox[] + + @@unique([worldStateId, targetRevision]) + @@index([status, createdAt]) + @@map("clock_suspension") +} + +model ClockReconciliationParticipant { + suspensionId String @map("suspension_id") @db.VarChar(64) + participantKey String @map("participant_key") @db.VarChar(96) + policy String + beforeChecksum String @map("before_checksum") @db.VarChar(128) + afterChecksum String @map("after_checksum") @db.VarChar(128) + affectedCount Int @default(0) @map("affected_count") + detail Json @default(dbgenerated("'{}'::jsonb")) + + suspension ClockSuspension @relation(fields: [suspensionId], references: [id], onDelete: Cascade) + + @@id([suspensionId, participantKey]) + @@map("clock_reconciliation_participant") +} + +model ClockProjectionOutbox { + id BigInt @id @default(autoincrement()) + worldStateId Int @map("world_state_id") + suspensionId String? @map("suspension_id") @db.VarChar(64) + targetRevision BigInt @map("target_revision") + status String @default("PENDING") + payload Json @default(dbgenerated("'{}'::jsonb")) + checksum String @db.VarChar(128) + attempts Int @default(0) + availableAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("available_at") @db.Timestamp(3) + lockedAt DateTime? @map("locked_at") @db.Timestamp(3) + lockedBy String? @map("locked_by") @db.VarChar(128) + appliedAt DateTime? @map("applied_at") @db.Timestamp(3) + lastError String? @map("last_error") + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3) + + worldState WorldState @relation(fields: [worldStateId], references: [id], onDelete: Cascade) + suspension ClockSuspension? @relation(fields: [suspensionId], references: [id], onDelete: Cascade) + + @@unique([worldStateId, targetRevision]) + @@index([status, availableAt, id]) + @@map("clock_projection_outbox") +} + model Nation { id Int @id name String @@ -351,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") @@ -775,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") @@ -815,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/20260903090000_add_game_clock_reconciliation/migration.sql b/packages/infra/prisma/migrations/20260903090000_add_game_clock_reconciliation/migration.sql new file mode 100644 index 00000000..31a24ce7 --- /dev/null +++ b/packages/infra/prisma/migrations/20260903090000_add_game_clock_reconciliation/migration.sql @@ -0,0 +1,81 @@ +ALTER TABLE world_state + ADD COLUMN clock_phase TEXT NOT NULL DEFAULT 'RUNNING', + ADD COLUMN clock_revision BIGINT NOT NULL DEFAULT 1, + ADD COLUMN deadline_generation BIGINT NOT NULL DEFAULT 1; + +UPDATE world_state +SET clock_phase = CASE + WHEN clock_mode = 'manual' THEN 'MANUAL' + WHEN clock_wall_anchor IS NOT NULL AND clock_wall_anchor > CURRENT_TIMESTAMP THEN 'PREOPEN' + ELSE 'RUNNING' + END; + +ALTER TABLE input_event + ADD COLUMN accepted_game_tick BIGINT, + ADD COLUMN accepted_clock_revision BIGINT; + +CREATE TABLE clock_suspension ( + id VARCHAR(64) PRIMARY KEY, + world_state_id INTEGER NOT NULL REFERENCES world_state(id) ON DELETE CASCADE, + source TEXT NOT NULL, + policy TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'SUSPENDED', + source_revision BIGINT NOT NULL, + target_revision BIGINT NOT NULL, + cut_tick BIGINT NOT NULL, + cut_wall_at TIMESTAMP(3) NOT NULL, + resume_wall_at TIMESTAMP(3), + rate_ticks_per_second INTEGER NOT NULL, + catch_up_ticks BIGINT NOT NULL DEFAULT 0, + gap_ticks BIGINT, + shift_ticks BIGINT, + aligned_tick BIGINT, + participant_checksum_before VARCHAR(128), + participant_checksum_after VARCHAR(128), + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + updated_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + CONSTRAINT clock_suspension_world_revision_key UNIQUE (world_state_id, target_revision), + CONSTRAINT clock_suspension_revision_step CHECK (target_revision = source_revision + 1), + CONSTRAINT clock_suspension_nonnegative_catchup CHECK (catch_up_ticks >= 0), + CONSTRAINT clock_suspension_positive_rate CHECK (rate_ticks_per_second > 0) +); + +CREATE INDEX clock_suspension_status_created_at_idx ON clock_suspension(status, created_at); + +CREATE TABLE clock_reconciliation_participant ( + suspension_id VARCHAR(64) NOT NULL REFERENCES clock_suspension(id) ON DELETE CASCADE, + participant_key VARCHAR(96) NOT NULL, + policy TEXT NOT NULL, + before_checksum VARCHAR(128) NOT NULL, + after_checksum VARCHAR(128) NOT NULL, + affected_count INTEGER NOT NULL DEFAULT 0, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (suspension_id, participant_key), + CONSTRAINT clock_reconciliation_participant_policy_check CHECK (policy IN ('SHIFT', 'KEEP', 'REBUILD', 'FORBID')), + CONSTRAINT clock_reconciliation_participant_count_check CHECK (affected_count >= 0) +); + +CREATE TABLE clock_projection_outbox ( + id BIGSERIAL PRIMARY KEY, + world_state_id INTEGER NOT NULL REFERENCES world_state(id) ON DELETE CASCADE, + suspension_id VARCHAR(64) REFERENCES clock_suspension(id) ON DELETE CASCADE, + target_revision BIGINT NOT NULL, + status TEXT NOT NULL DEFAULT 'PENDING', + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + checksum VARCHAR(128) NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + available_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + locked_at TIMESTAMP(3), + locked_by VARCHAR(128), + applied_at TIMESTAMP(3), + last_error TEXT, + created_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + updated_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + CONSTRAINT clock_projection_outbox_world_revision_key UNIQUE (world_state_id, target_revision), + CONSTRAINT clock_projection_outbox_status_check CHECK (status IN ('PENDING', 'APPLYING', 'APPLIED', 'FAILED')), + CONSTRAINT clock_projection_outbox_attempts_check CHECK (attempts >= 0) +); + +CREATE INDEX clock_projection_outbox_status_available_at_id_idx + ON clock_projection_outbox(status, available_at, id); diff --git a/packages/infra/prisma/migrations/20260903103000_add_input_event_clock_processing/migration.sql b/packages/infra/prisma/migrations/20260903103000_add_input_event_clock_processing/migration.sql new file mode 100644 index 00000000..0f24fcf8 --- /dev/null +++ b/packages/infra/prisma/migrations/20260903103000_add_input_event_clock_processing/migration.sql @@ -0,0 +1,15 @@ +ALTER TABLE input_event + ADD COLUMN accepted_deadline_generation BIGINT, + ADD COLUMN processing_game_tick BIGINT, + ADD COLUMN processing_clock_revision BIGINT, + ADD COLUMN processing_deadline_generation BIGINT; + +ALTER TABLE input_event + ADD CONSTRAINT input_event_accepted_clock_revision_positive + CHECK (accepted_clock_revision IS NULL OR accepted_clock_revision > 0), + ADD CONSTRAINT input_event_accepted_deadline_generation_positive + CHECK (accepted_deadline_generation IS NULL OR accepted_deadline_generation > 0), + ADD CONSTRAINT input_event_processing_clock_revision_positive + CHECK (processing_clock_revision IS NULL OR processing_clock_revision > 0), + ADD CONSTRAINT input_event_processing_deadline_generation_positive + CHECK (processing_deadline_generation IS NULL OR processing_deadline_generation > 0); 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/20260903183000_turn_daemon_lease_utc_wall/migration.sql b/packages/infra/prisma/migrations/20260903183000_turn_daemon_lease_utc_wall/migration.sql new file mode 100644 index 00000000..aa3f0824 --- /dev/null +++ b/packages/infra/prisma/migrations/20260903183000_turn_daemon_lease_utc_wall/migration.sql @@ -0,0 +1,20 @@ +-- A daemon lease is an operational WALL_TIME deadline. The game database keeps +-- legacy DateTime columns as TIMESTAMP(3), while its session timezone can be +-- Asia/Seoul, so bare CURRENT_TIMESTAMP writes were nine hours ahead of the UTC +-- comparisons used by clock fencing. +-- +-- Lease rows are ephemeral authority, not business history. Expire every row at +-- the migration boundary so an old writer is fenced and a new UTC-aware daemon +-- must acquire a fresh epoch. This also makes mixed-version deployment fail +-- closed instead of preserving a falsely-live lease. +BEGIN; + +UPDATE "turn_daemon_lease" +SET + "lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + "heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'; + +ALTER TABLE "turn_daemon_lease" + ALTER COLUMN "heartbeat_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +COMMIT; diff --git a/packages/infra/prisma/migrations/20260903201500_complete_invader_game_clock/migration.sql b/packages/infra/prisma/migrations/20260903201500_complete_invader_game_clock/migration.sql new file mode 100644 index 00000000..1fa87b74 --- /dev/null +++ b/packages/infra/prisma/migrations/20260903201500_complete_invader_game_clock/migration.sql @@ -0,0 +1,33 @@ +-- InvaderEnding is the terminal gameplay boundary. Older rows can have +-- isUnited=3 while the logical game clock remains RUNNING because the handler +-- historically removed its event without completing the clock. +-- +-- Deployment runs game-schema migrations while the profile runtime is stopped, +-- so this backfill installs the missing terminal state without racing a daemon. +-- Any unchosen raise-invader alternatives are no longer actionable after the +-- world leaves the unification-wait state; close their GAME_TIME action state at +-- the terminal authoritative tick while preserving the WALL_TIME envelopes. +BEGIN; + +UPDATE "world_state" +SET "clock_phase" = 'COMPLETED' +WHERE "clock_phase" IN ('RUNNING', 'MANUAL') + AND GREATEST( + CASE WHEN "meta"->>'isUnited' ~ '^[0-9]+$' THEN ("meta"->>'isUnited')::integer ELSE 0 END, + CASE WHEN "meta"->>'isunited' ~ '^[0-9]+$' THEN ("meta"->>'isunited')::integer ELSE 0 END + ) >= 3; + +UPDATE "message_action" AS action +SET "status" = 'RESOLVED', + "resolved_game_tick" = GREATEST(action."created_game_tick", world."clock_tick"), + "updated_at_wall" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' +FROM "world_state" AS world +WHERE action."action_type" = 'raiseInvader' + AND action."status" = 'PENDING' + AND world."clock_tick" IS NOT NULL + AND GREATEST( + CASE WHEN world."meta"->>'isUnited' ~ '^[0-9]+$' THEN (world."meta"->>'isUnited')::integer ELSE 0 END, + CASE WHEN world."meta"->>'isunited' ~ '^[0-9]+$' THEN (world."meta"->>'isunited')::integer ELSE 0 END + ) <> 2; + +COMMIT; diff --git a/packages/infra/prisma/migrations/README.md b/packages/infra/prisma/migrations/README.md index 0d1cf44e..dd929e82 100644 --- a/packages/infra/prisma/migrations/README.md +++ b/packages/infra/prisma/migrations/README.md @@ -27,6 +27,8 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여 - `world_state`, `nation`, `city`, `general`, `message`, `troop` - `general_turn`, `nation_turn`과 revision·lease field - `input_event`, `turn_daemon_lease` +- KST session에서도 `turn_daemon_lease` acquire/renew/release가 DB UTC wall + deadline을 기록하며, migration 시 기존 ephemeral lease가 만료되는지 - `read_model_revision`, `read_model_outbox`, `read_model_revision_meta`, `web_push_outbox` - 두 outbox의 `available_at`, `locked_at`, `delivered_at`, `created_at`은 millisecond 정밀도 `timestamp without time zone`을 유지한다. 이 migration @@ -48,9 +50,32 @@ 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을 삭제하지 않습니다. +## Daemon lease와 이민족 종료 upgrade + +`20260903183000_turn_daemon_lease_utc_wall`은 `timestamp without time zone` +lease를 DB UTC 기준으로 통일하고 배포 시 기존 lease를 만료시킵니다. 이는 +영속 gameplay history가 아니라 재획득 가능한 운영 authority이므로 새 daemon이 +fresh fencing epoch를 얻는 것이 backfill 계약입니다. + +`20260903201500_complete_invader_game_clock`은 이미 `isUnited=3`인데 +`RUNNING`/`MANUAL`인 legacy world를 `COMPLETED`로 바꾸고, 더 이상 선택할 수 없는 +pending `raiseInvader` action을 terminal game tick에 resolve합니다. 메시지 +`created_at_wall`, `delete_until_wall`과 기타 WALL_TIME envelope 값은 변경하지 +않습니다. + ## Game outbox UTC-wall populated upgrade 검증 Git에서 제외된 전용 PostgreSQL URL을 주입해 target 직전 migration chain부터 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 aa1dfede..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; @@ -46,6 +48,9 @@ export interface DatabaseClient { vote: GamePrisma.VoteDelegate; inputEvent: GamePrisma.InputEventDelegate; turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate; + clockSuspension?: GamePrisma.ClockSuspensionDelegate; + clockReconciliationParticipant?: GamePrisma.ClockReconciliationParticipantDelegate; + clockProjectionOutbox?: GamePrisma.ClockProjectionOutboxDelegate; readModelOutbox: GamePrisma.ReadModelOutboxDelegate; webPushOutbox: GamePrisma.WebPushOutboxDelegate; } diff --git a/packages/infra/src/gameSchemaAdvisoryLock.ts b/packages/infra/src/gameSchemaAdvisoryLock.ts index ebb0efc0..7cd82616 100644 --- a/packages/infra/src/gameSchemaAdvisoryLock.ts +++ b/packages/infra/src/gameSchemaAdvisoryLock.ts @@ -8,6 +8,8 @@ interface TryLockRow { /** Serializes score/traffic writers whose table lock order otherwise differs by entry point. */ export const GENERAL_ACCESS_PERSISTENCE_LOCK = 'general-access:persistence'; +/** Serializes phase/revision changes with every gameplay flush in one game schema. */ +export const CLOCK_OPERATION_PERSISTENCE_LOCK = 'game-clock:operation'; const lockKeySql = (logicalKey: string): GamePrisma.Sql => GamePrisma.sql`hashtextextended(current_schema() || chr(31) || ${logicalKey}, 0)`; diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index 63c8ddfa..6100c22a 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -10,4 +10,6 @@ export * from './readModelChangeJournal.js'; 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/inputEventClock.ts b/packages/infra/src/inputEventClock.ts new file mode 100644 index 00000000..c4eb121f --- /dev/null +++ b/packages/infra/src/inputEventClock.ts @@ -0,0 +1,76 @@ +import { GameClock, inferClockPhase, parseGameClockPhase } from '@sammo-ts/common'; + +import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; +import { acquireGameSchemaAdvisoryXactLock, CLOCK_OPERATION_PERSISTENCE_LOCK } from './gameSchemaAdvisoryLock.js'; + +type ClockAcceptanceDatabase = Pick; + +interface DbWallRow { + wallNow: Date; +} + +export interface InputEventClockCoordinate { + wallAt: Date; + gameAt: Date; + gameTick: bigint; + clockRevision: bigint; + deadlineGeneration: bigint; + phase: string; +} + +/** + * Reads one input-event acceptance coordinate while holding the same schema + * clock-operation fence used by reconciliation. The caller must create the + * input_event in this transaction before releasing the lock. + */ +export const readInputEventClockCoordinate = async ( + db: ClockAcceptanceDatabase +): Promise => { + await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK); + const [wall] = await db.$queryRaw(GamePrisma.sql` + SELECT timezone('UTC', clock_timestamp()) AS "wallNow" + `); + if (!wall) throw new Error('PostgreSQL did not return its authoritative wall clock.'); + const world = await db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { + clockBaseTime: true, + clockTick: true, + clockMode: true, + clockWallAnchor: true, + tickSeconds: true, + clockPhase: true, + clockRevision: true, + deadlineGeneration: true, + }, + }); + if (!world?.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) { + throw new Error('The authoritative game clock is not initialized.'); + } + const tick = Number(world.clockTick); + const revision = Number(world.clockRevision); + const generation = Number(world.deadlineGeneration); + if (!Number.isSafeInteger(tick) || !Number.isSafeInteger(revision) || !Number.isSafeInteger(generation)) { + throw new Error('The authoritative game clock coordinate is outside the safe integer range.'); + } + const mode = world.clockMode === 'manual' ? 'manual' : 'realtime'; + const phase = world.clockPhase ? parseGameClockPhase(world.clockPhase) : inferClockPhase(mode); + const clock = new GameClock({ + baseTime: world.clockBaseTime, + tick, + mode, + wallAnchor: world.clockWallAnchor, + turnSeconds: world.tickSeconds, + phase, + revision, + }); + const observedTick = clock.nowTick(wall.wallNow); + return { + wallAt: wall.wallNow, + gameAt: clock.tickToDate(observedTick), + gameTick: BigInt(observedTick), + clockRevision: BigInt(revision), + deadlineGeneration: BigInt(generation), + phase, + }; +}; diff --git a/packages/infra/src/messageEnvelope.ts b/packages/infra/src/messageEnvelope.ts new file mode 100644 index 00000000..671d75c3 --- /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 CAST(${actionType} AS text) 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/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index 7eb2b1b8..44aad37e 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -16,6 +16,9 @@ export interface TurnEngineWorldStateRow { clockMode: string; clockWallAnchor: Date | null; lastTurnTick: bigint | null; + clockPhase: string; + clockRevision: bigint; + deadlineGeneration: bigint; config: JsonValue; meta: JsonValue; updatedAt?: Date | null; @@ -181,6 +184,9 @@ export interface TurnEngineWorldStateUpdateInput { clockMode: string; clockWallAnchor: Date; lastTurnTick: bigint; + clockPhase: string; + clockRevision: bigint; + deadlineGeneration: bigint; config: InputJsonValue; meta: InputJsonValue; } @@ -195,6 +201,9 @@ export interface TurnEngineWorldStateCreateInput { clockMode: string; clockWallAnchor: Date; lastTurnTick: bigint; + clockPhase: string; + clockRevision: bigint; + deadlineGeneration: bigint; config: InputJsonValue; meta: InputJsonValue; } 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/pnpm-lock.yaml b/pnpm-lock.yaml index eca43567..1c50705b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -595,7 +595,11 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) - tools/load-tests: {} + tools/load-tests: + devDependencies: + tsx: + specifier: ^4.23.12 + version: 4.23.12 packages: diff --git a/release-manifest.json b/release-manifest.json index 5e0bb889..9373355e 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260825000000_add_bulk_release_batches", - "gameSchemaHead": "20260824080000_vote_utc_wall_timestamps", + "gameSchemaHead": "20260903201500_complete_invader_game_clock", "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 new file mode 100644 index 00000000..a630982e --- /dev/null +++ b/tools/check-game-clock-participants.mjs @@ -0,0 +1,121 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const schemaPath = path.join(root, 'packages/infra/prisma/game.prisma'); +const inventoryPath = path.join(root, 'docs/architecture/game-clock-participants.json'); +const [schema, inventoryText] = await Promise.all([readFile(schemaPath, 'utf8'), readFile(inventoryPath, 'utf8')]); +const inventory = JSON.parse(inventoryText); +const covered = new Set(inventory.coveredFields ?? []); +const policies = new Set(inventory.policies ?? []); +const failures = []; +const participantKeys = new Set(); + +if (inventory.tickPerTurn !== 36_000_000) { + failures.push(`tickPerTurn must remain 36000000, found ${inventory.tickPerTurn}`); +} + +const discovered = []; +for (const modelMatch of schema.matchAll(/model\s+(\w+)\s*\{([\s\S]*?)\n\}/g)) { + const [, modelName, body] = modelMatch; + const table = body.match(/@@map\("([^"]+)"\)/)?.[1] ?? modelName; + for (const fieldMatch of body.matchAll(/^\s*(\w+)\s+BigInt\??[^\n]*@map\("([^"]+)"\)/gm)) { + const databaseField = fieldMatch[2]; + if ( + 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}`); + } + } +} + +for (const field of discovered) { + if (!covered.has(field)) { + failures.push(`unregistered authoritative clock field: ${field}`); + } +} +for (const participant of inventory.participants ?? []) { + if (participantKeys.has(participant.key)) { + failures.push(`duplicate participant key: ${participant.key}`); + } + participantKeys.add(participant.key); + if (!policies.has(participant.policy)) { + failures.push(`participant ${participant.key} has unknown policy ${participant.policy}`); + } + if (participant.policy === 'FORBID') { + failures.push(`active participant must not remain FORBID: ${participant.key}`); + } + if (!participant.owner || !Array.isArray(participant.authorityFields)) { + failures.push(`participant ${participant.key} is missing owner or authorityFields`); + } +} +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-action-occurrence', + 'message-action-expiry', + 'message-action-clock-coordinate', + 'inheritance-effect-coordinate', + 'vote-start-occurrence', + 'vote-end-deadline', + 'select-pool-reservation', + 'npc-selection-window', + 'daemon-command-coordinate', + 'tournament-deadlines', + 'movable-json-rule-anchors', + 'unification-wait', + 'clock-operation-ledger', +]) { + if (!participantKeys.has(requiredKey)) { + failures.push(`required participant is missing: ${requiredKey}`); + } +} + +const redisKeyPatterns = new Set(); +for (const entry of inventory.redis ?? []) { + if (!entry.keyPattern) { + failures.push('Redis participant is missing keyPattern'); + continue; + } + if (redisKeyPatterns.has(entry.keyPattern)) { + failures.push(`duplicate Redis participant: ${entry.keyPattern}`); + } + redisKeyPatterns.add(entry.keyPattern); + if (!policies.has(entry.policy) || entry.policy === 'FORBID') { + failures.push(`Redis participant ${entry.keyPattern} has invalid policy ${entry.policy}`); + } + if (typeof entry.status !== 'string' || !entry.status.startsWith('implemented-')) { + failures.push(`Redis participant ${entry.keyPattern} is not implemented: ${entry.status ?? 'missing status'}`); + } +} +for (const requiredKeyPattern of [ + 'sammo:{profile}:clock:active-revision', + 'sammo:{profile}:auction:timer', + 'sammo:{profile}:tournament:state', +]) { + if (!redisKeyPatterns.has(requiredKeyPattern)) { + failures.push(`required Redis participant is missing: ${requiredKeyPattern}`); + } +} + +if (failures.length > 0) { + console.error(failures.join('\n')); + process.exitCode = 1; +} else { + console.log( + `Validated ${discovered.length} authoritative clock fields and ${inventory.participants.length} participants.` + ); +} diff --git a/tools/integration-tests/scripts/live-ten-user-lifecycle.ts b/tools/integration-tests/scripts/live-ten-user-lifecycle.ts new file mode 100644 index 00000000..cdc47460 --- /dev/null +++ b/tools/integration-tests/scripts/live-ten-user-lifecycle.ts @@ -0,0 +1,2529 @@ +/// + +import { constants, publicEncrypt, randomUUID } from 'node:crypto'; +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { chromium, type BrowserContext, type Page } from '@playwright/test'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; +import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; +import { createGamePostgresConnector, createRedisConnector, type GamePrisma } from '@sammo-ts/infra'; +import { applyNextClockProjection } from '../../../app/game-engine/src/turn/clockProjectionOutbox.js'; +import { reconcileClockSuspension } from '../../../app/game-engine/src/turn/clockReconciliation.js'; +import { createTurnDaemonRuntime } from '../../../app/game-engine/src/turn/turnDaemon.js'; + +const gatewayUrl = process.env.SAMMO_LIVE_GATEWAY_URL ?? 'http://caddy/gateway/api/trpc'; +const webOrigin = process.env.SAMMO_LIVE_WEB_ORIGIN ?? 'http://caddy'; +const profileName = process.env.SAMMO_LIVE_PROFILE ?? 'hwe:default'; +const sourceRef = process.env.SAMMO_LIVE_SOURCE_REF ?? 'test/game-clock-reconciliation-20260903'; +const artifactDir = path.resolve( + process.env.SAMMO_LIVE_ARTIFACT_DIR ?? + path.resolve(import.meta.dirname, '../../../test-results/live-ten-user-lifecycle-20260903') +); +const statePath = path.join(artifactDir, 'state.json'); + +type State = { + runId: string; + requestedAt: string; + preopenAt: string; + openAt: string; + resetOperationId: string; + deployOperationId?: string; + users?: Array<{ + username: string; + displayName: string; + gatewayToken: string; + gameToken: string; + generalName: string; + }>; + preopenMessages?: Array<{ index: number; text: string; wallSentAt: string; wallObservedAt: string }>; + verifiedPreopenMessages?: Array<{ index: number; text: string; wallSentAt: string; wallObservedAt: string }>; + pausedBettingId?: number; + actionFixture?: { + nations: Array<{ id: number; name: string; capitalCityId: number }>; + generals: Array<{ + index: number; + id: number; + nationId: number; + cityId: number; + officerLevel: number; + }>; + }; + pausedWallExpiryMessage?: { + generalIndex: number; + messageId: number; + createdAtWall: string; + frozenGameTick: string; + }; + actionableMessages?: { recruitmentId: number; noAggressionId: number }; + cancelNoAggressionMessageId?: number; + pausedTournamentBet?: { + bettorGeneralId: number; + targetGeneralId: number; + preparedGameTick: string; + }; +}; + +const log = (event: string, detail: Record = {}): void => { + process.stdout.write(`${JSON.stringify({ at: new Date().toISOString(), event, ...detail })}\n`); +}; + +const requiredEnv = (name: string): string => { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +}; + +const createGateway = (session: { token?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: gatewayUrl, + headers: () => (session.token ? { 'x-session-token': session.token } : {}), + }), + ], + }); + +const createGame = (token?: string) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${webOrigin}/hwe/api/trpc`, + headers: token ? { authorization: `Bearer ${token}` } : {}, + }), + ], + }); + +const loginAdminWithToken = async () => { + const session: { token?: string } = {}; + const gateway = createGateway(session); + const passwordKey = await gateway.auth.passwordKey.query(); + const credential = { + keyId: passwordKey.keyId, + ciphertext: publicEncrypt( + { + key: passwordKey.publicKeyPem, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + Buffer.from(requiredEnv('INITIAL_ADMIN_PASSWORD'), 'utf8') + ).toString('base64'), + }; + const result = await gateway.auth.login.mutate({ + username: requiredEnv('INITIAL_ADMIN_USERNAME'), + credential, + }); + if (result.status === 'otp') throw new Error('Admin login unexpectedly requires OTP.'); + session.token = result.sessionToken; + return { gateway, sessionToken: result.sessionToken }; +}; + +const loginAdmin = async () => (await loginAdminWithToken()).gateway; + +const createAdminGame = async () => { + const { gateway, sessionToken } = await loginAdminWithToken(); + const issued = await gateway.auth.issueGameSession.mutate({ sessionToken, profile: profileName }); + const exchanged = await createGame().auth.exchangeGatewayToken.mutate({ gatewayToken: issued.gameToken }); + return createGame(exchanged.accessToken); +}; + +const readState = async (): Promise => JSON.parse(await readFile(statePath, 'utf8')) as State; +const writeState = async (state: State): Promise => { + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); +}; + +const gameDatabaseUrl = (): string => { + const url = process.env.DATABASE_URL + ? new URL(process.env.DATABASE_URL) + : new URL( + `postgresql://${encodeURIComponent(process.env.POSTGRES_USER ?? 'sammo')}:${encodeURIComponent(requiredEnv('POSTGRES_PASSWORD'))}@${process.env.POSTGRES_HOST ?? 'postgres'}:${process.env.POSTGRES_PORT ?? '5432'}/${process.env.POSTGRES_DB ?? 'sammo'}` + ); + url.searchParams.set('schema', profileName.split(':', 1)[0] ?? 'hwe'); + return url.toString(); +}; + +const redisUrl = (): string => { + const url = new URL('redis://redis/0'); + url.hostname = process.env.REDIS_HOST ?? 'redis'; + url.port = process.env.REDIS_PORT ?? '6379'; + url.password = requiredEnv('REDIS_PASSWORD'); + return url.toString(); +}; + +const reset = async (): Promise => { + await mkdir(artifactDir, { recursive: true }); + const gateway = await loginAdmin(); + const requestedAt = new Date(); + const preopenAt = new Date(requestedAt.getTime() + 30_000); + const openAt = new Date(requestedAt.getTime() + 30 * 60_000); + const operation = await gateway.admin.operations.requestReset.mutate({ + profileName, + sourceMode: 'BRANCH', + sourceRef, + install: { + scenarioId: 2601, + turnTermMinutes: 1, + sync: true, + fiction: 1, + extend: true, + blockGeneralCreate: 0, + npcMode: 2, + showImgLevel: 3, + tournamentTrig: true, + joinMode: 'full', + autorunUser: null, + preopenAt: preopenAt.toISOString(), + openAt: openAt.toISOString(), + }, + publishSchedule: false, + reason: 'isolated ten-user PREOPEN lifecycle integration', + }); + const state: State = { + runId: randomUUID(), + requestedAt: requestedAt.toISOString(), + preopenAt: preopenAt.toISOString(), + openAt: openAt.toISOString(), + resetOperationId: operation.id, + }; + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + log('reset-requested', { + operationId: operation.id, + profileName, + scenarioId: 2601, + turnTermMinutes: 1, + preopenAt: state.preopenAt, + openAt: state.openAt, + }); +}; + +const waitOperation = async (operationId: string, eventPrefix: string): Promise => { + const gateway = await loginAdmin(); + let cursor: string | undefined; + let lastHeartbeatAt = 0; + while (true) { + const result = await gateway.admin.operations.logs.query({ + id: operationId, + afterCursor: cursor, + limit: 200, + timeoutMs: 20_000, + }); + cursor = result.nextCursor; + const notable = result.entries.filter((entry) => entry.level !== 'OUTPUT'); + if (notable.length > 0 || Date.now() - lastHeartbeatAt >= 20_000) { + lastHeartbeatAt = Date.now(); + log(`${eventPrefix}-progress`, { + status: result.operation.status, + receivedLogEntries: result.entries.length, + phases: notable.map((entry) => `${entry.level}:${entry.phase}`), + }); + } + if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(result.operation.status)) { + log(`${eventPrefix}-terminal`, { + status: result.operation.status, + error: result.operation.error ?? null, + completedAt: result.operation.completedAt ?? null, + }); + if (result.operation.status !== 'SUCCEEDED') process.exitCode = 1; + return; + } + } +}; + +const waitReset = async (): Promise => { + const state = await readState(); + await waitOperation(state.resetOperationId, 'reset'); +}; + +const deploy = async (): Promise => { + const state = await readState(); + const gateway = await loginAdmin(); + const operation = await gateway.admin.operations.requestDeploy.mutate({ + profileName, + sourceMode: 'BRANCH', + sourceRef, + reason: 'deploy suspended-action lifecycle fixes without resetting the isolated season', + }); + state.deployOperationId = operation.id; + await writeState(state); + log('deploy-requested', { operationId: operation.id, profileName, sourceMode: 'BRANCH', sourceRef }); +}; + +const waitDeploy = async (): Promise => { + const state = await readState(); + if (!state.deployOperationId) throw new Error('No lifecycle deploy operation was requested.'); + await waitOperation(state.deployOperationId, 'deploy'); +}; + +const status = async (): Promise => { + const gateway = await loginAdmin(); + const profiles = (await gateway.admin.profiles.list.query()) as unknown as Array< + { profileName: string } & Record + >; + const profile = profiles.find((entry) => entry.profileName === profileName); + if (!profile) throw new Error(`Profile not found: ${profileName}`); + log('profile-status', profile); +}; + +const prepareUsers = async (): Promise => { + const state = await readState(); + if ((state.users?.length ?? 0) > 10) throw new Error('Lifecycle state contains more than ten users.'); + const browser = await chromium.launch({ headless: true }); + const runSlug = state.runId.replaceAll('-', '').slice(0, 8); + const password = `Live-${state.runId}`; + const users: NonNullable = [...(state.users ?? [])]; + let browserErrors = 0; + let applicationHttpErrors = 0; + try { + for (let index = users.length + 1; index <= 10; index += 1) { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + page.setDefaultTimeout(30_000); + page.on('pageerror', () => { + browserErrors += 1; + }); + page.on('response', (response) => { + if (response.status() >= 400 && !new URL(response.url()).pathname.startsWith('/image/')) { + applicationHttpErrors += 1; + } + }); + const suffix = String(index).padStart(2, '0'); + const username = `live${runSlug}${suffix}`; + const displayName = `통합${runSlug.slice(0, 4)}${suffix}`; + const generalName = `통합장수${suffix}`; + await page.goto(`${webOrigin}/gateway/signup`, { waitUntil: 'networkidle' }); + await page.locator('#signup-username').fill(username); + await page.locator('#signup-password').fill(password); + await page.locator('#signup-confirm-password').fill(password); + await page.locator('#signup-display-name').fill(displayName); + await page.locator('#signup-form input[type="checkbox"]').nth(0).check(); + await page.locator('#signup-form input[type="checkbox"]').nth(1).check(); + await page.getByRole('button', { name: '가입', exact: true }).click(); + const registrationResult = await Promise.race([ + page.waitForURL(/\/gateway\/lobby/u).then(() => 'registered' as const), + page + .locator('.signup-error') + .waitFor({ state: 'visible' }) + .then(() => 'rejected' as const), + ]); + if (registrationResult === 'rejected') { + const registrationError = (await page.locator('.signup-error').innerText()).trim(); + if (!registrationError.includes('이미 사용')) { + throw new Error(`Gateway registration failed for viewer ${index}: ${registrationError}`); + } + await page.goto(`${webOrigin}/gateway/`, { waitUntil: 'networkidle' }); + await page.locator('#username').fill(username); + await page.locator('#password').fill(password); + await page.getByRole('button', { name: '로그인', exact: true }).click(); + await page.waitForURL(/\/gateway\/lobby/u); + } + const gatewayToken = await page.evaluate(() => window.localStorage.getItem('sammo-session-token')); + if (!gatewayToken) throw new Error(`Gateway session was not persisted for viewer ${index}.`); + + const createButton = page.getByRole('button', { name: '장수생성', exact: true }); + await createButton.waitFor({ state: 'visible' }).catch(async () => { + await page.reload({ waitUntil: 'networkidle' }); + await createButton.waitFor({ state: 'visible', timeout: 60_000 }); + }); + await createButton.click(); + await page.waitForURL(/\/hwe\/join/u); + await page.getByLabel('장수명').fill(generalName); + await page.getByRole('button', { name: '능력치 초기화', exact: true }).click(); + await page.locator('.create-form').getByRole('button', { name: '장수 생성', exact: true }).click(); + const dialog = page.getByTestId('game-notice-dialog'); + await dialog.waitFor({ state: 'visible' }); + const dialogText = await dialog.innerText(); + if (!dialogText.includes('장수를 생성했습니다')) { + throw new Error(`General creation failed for viewer ${index}: ${dialogText}`); + } + await dialog.getByRole('button', { name: '확인', exact: true }).click(); + await page.waitForURL(/\/hwe\/(?:$|\?)/u); + const gameToken = await page.evaluate(() => window.localStorage.getItem('sammo-game-token')); + if (!gameToken?.startsWith('ga_')) throw new Error(`Game access token is missing for viewer ${index}.`); + if (index === 1) { + await page.screenshot({ path: path.join(artifactDir, 'preopen-user-01-main.png'), fullPage: true }); + } + users.push({ username, displayName, gatewayToken, gameToken, generalName }); + state.users = users; + await writeState(state); + log('user-created', { index, username, displayName, generalName }); + await context.close(); + } + } finally { + await browser.close(); + } + state.users = users; + await writeState(state); + if (!process.env.DATABASE_URL) { + log('users-prepared', { + users: users.length, + browserErrors, + applicationHttpErrors, + databaseSnapshot: 'deferred-to-runtime', + }); + if (browserErrors || applicationHttpErrors || users.length !== 10) process.exitCode = 1; + return; + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const [world, humanGenerals, allGenerals] = await Promise.all([ + db.prisma.worldState.findFirstOrThrow({ + select: { + scenarioCode: true, + currentYear: true, + currentMonth: true, + tickSeconds: true, + clockPhase: true, + clockTick: true, + clockWallAnchor: true, + }, + }), + db.prisma.general.count({ where: { userId: { not: null }, npcState: 0 } }), + db.prisma.general.count(), + ]); + log('users-prepared', { + users: users.length, + browserErrors, + applicationHttpErrors, + humanGenerals, + allGenerals, + world: { ...world, clockTick: world.clockTick?.toString() ?? null }, + }); + if (browserErrors || applicationHttpErrors || users.length !== 10 || humanGenerals !== 10) process.exitCode = 1; + } finally { + await db.disconnect(); + } +}; + +const openUserPages = async () => { + const state = await readState(); + if (state.users?.length !== 10) throw new Error('Exactly ten lifecycle users are required.'); + const browser = await chromium.launch({ headless: true }); + const records: Array<{ + index: number; + context: BrowserContext; + page: Page; + }> = []; + let browserErrors = 0; + let applicationHttpErrors = 0; + for (const [offset, user] of state.users.entries()) { + const index = offset + 1; + const context = await browser.newContext({ + viewport: index === 10 ? { width: 390, height: 844 } : { width: 1280, height: 900 }, + }); + await context.addInitScript( + ({ gatewayToken, gameToken, profile }) => { + window.localStorage.setItem('sammo-session-token', gatewayToken); + window.localStorage.setItem('sammo-game-token', gameToken); + window.localStorage.setItem('sammo-game-profile', profile); + }, + { gatewayToken: user.gatewayToken, gameToken: user.gameToken, profile: profileName } + ); + const page = await context.newPage(); + page.setDefaultTimeout(30_000); + page.on('pageerror', () => { + browserErrors += 1; + }); + page.on('response', (response) => { + if (response.status() >= 400 && !new URL(response.url()).pathname.startsWith('/image/')) { + applicationHttpErrors += 1; + } + }); + await page.goto(`${webOrigin}/hwe/`, { waitUntil: 'domcontentloaded' }); + await page.locator('.MessagePanel').waitFor({ state: 'visible' }); + records.push({ index, context, page }); + } + return { + state, + browser, + records, + errors: () => ({ browserErrors, applicationHttpErrors }), + }; +}; + +const preopenMessages = async (verifiedPhase = false): Promise => { + const opened = await openUserPages(); + const prefix = `PREOPEN${verifiedPhase ? '-VERIFIED' : ''}-${opened.state.runId.replaceAll('-', '').slice(0, 8)}`; + const messages: NonNullable = []; + try { + for (const { index, page } of opened.records) { + const text = `${prefix}-${String(index).padStart(2, '0')}`; + const wallSentAt = new Date().toISOString(); + await page.locator('.PublicTalk').getByRole('button', { name: '↩ 여기로', exact: true }).click(); + await page.locator('.message-text').fill(text); + await page.getByRole('button', { name: '서신전달&갱신', exact: true }).click(); + await page.locator('.PublicTalk').getByText(text, { exact: true }).waitFor({ state: 'visible' }); + const wallObservedAt = new Date().toISOString(); + messages.push({ index, text, wallSentAt, wallObservedAt }); + log('preopen-message-sent', { index, text, wallSentAt, wallObservedAt }); + } + for (const { index, page } of opened.records) { + await page.waitForFunction( + ({ selector, expected }) => { + const text = document.querySelector(selector)?.textContent ?? ''; + return expected.every((entry) => text.includes(entry)); + }, + { selector: '.PublicTalk', expected: messages.map((message) => message.text) }, + { timeout: 30_000 } + ); + log('preopen-message-fanout', { viewer: index, observed: messages.length }); + } + await opened.records[0]!.page.screenshot({ + path: path.join(artifactDir, 'preopen-public-messages-desktop.png'), + fullPage: true, + }); + await opened.records[9]!.page.screenshot({ + path: path.join(artifactDir, 'preopen-public-messages-mobile.png'), + fullPage: true, + }); + if (verifiedPhase) opened.state.verifiedPreopenMessages = messages; + else opened.state.preopenMessages = messages; + await writeState(opened.state); + const errors = opened.errors(); + log('preopen-messages-complete', { sent: messages.length, fanoutChecks: 100, ...errors }); + if (errors.browserErrors || errors.applicationHttpErrors) process.exitCode = 1; + } finally { + await Promise.all(opened.records.map(({ context }) => context.close())); + await opened.browser.close(); + } +}; + +const repairPreopenFixture = async (): Promise => { + const state = await readState(); + if (Date.now() >= new Date(state.openAt).getTime()) throw new Error('Formal opening has already passed.'); + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + const redis = createRedisConnector({ url: redisUrl() }); + await db.connect(); + await redis.connect(); + try { + const world = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (world.clockTick !== 0n || world.clockWallAnchor?.toISOString() !== state.openAt) { + throw new Error('Refusing PREOPEN repair because tick zero or opening anchor differs.'); + } + await db.prisma.worldState.update({ where: { id: world.id }, data: { clockPhase: 'PREOPEN' } }); + await redis.client.set(`sammo:${profileName}:clock:phase`, 'PREOPEN'); + log('preopen-fixture-repaired', { + previousPhase: world.clockPhase, + nextPhase: 'PREOPEN', + clockTick: world.clockTick.toString(), + openAt: state.openAt, + reason: 'reset seeded with openAt as seed wall time and persisted RUNNING before Gateway promotion', + }); + } finally { + await redis.disconnect(); + await db.disconnect(); + } +}; + +const databaseStatus = async (): Promise => { + const state = await readState(); + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const [ + world, + humanGenerals, + humanGeneralDetails, + allGenerals, + nations, + cities, + messages, + monitorMessages, + betting, + unification, + unificationActions, + suspensions, + dbWallRows, + daemonLeases, + ] = await Promise.all([ + db.prisma.worldState.findFirstOrThrow({ + select: { + scenarioCode: true, + currentYear: true, + currentMonth: true, + tickSeconds: true, + clockPhase: true, + clockTick: true, + lastTurnTick: true, + clockWallAnchor: true, + clockRevision: true, + }, + }), + db.prisma.general.count({ where: { userId: { not: null }, npcState: 0 } }), + db.prisma.general.findMany({ + where: { userId: { not: null }, npcState: 0 }, + orderBy: { id: 'asc' }, + select: { id: true, name: true, nationId: true, officerLevel: true, turnTime: true, turnTick: true }, + }), + db.prisma.general.count(), + db.prisma.nation.count({ where: { id: { gt: 0 }, level: { gt: 0 } } }), + db.prisma.city.count(), + db.prisma.$queryRaw>` + SELECT id, time, time_tick AS "timeTick", message->>'text' AS text + FROM message + WHERE message->>'text' LIKE ${`PREOPEN%${state.runId.replaceAll('-', '').slice(0, 8)}%`} + ORDER BY id + `, + db.prisma.$queryRaw>` + SELECT id, time, time_tick AS "timeTick", message->>'text' AS text + FROM message + WHERE message->>'text' LIKE ${`MONITOR-${state.runId.replaceAll('-', '').slice(0, 8)}-%`} + ORDER BY id DESC + LIMIT 20 + `, + db.prisma.nationBetting.findMany({ + orderBy: { id: 'asc' }, + select: { id: true, name: true, finished: true, openYearMonth: true, closeYearMonth: true, bets: true }, + }), + db.prisma.unificationFinalization.findMany({ orderBy: { createdAt: 'asc' } }), + db.prisma.messageAction.findMany({ + where: { actionType: 'raiseInvader' }, + orderBy: { messageId: 'asc' }, + include: { + message: { + select: { + id: true, + mailbox: true, + createdAtWall: true, + occurredGameTick: true, + message: true, + }, + }, + }, + }), + db.prisma.clockSuspension.findMany({ + orderBy: { createdAt: 'asc' }, + select: { + id: true, + status: true, + sourceRevision: true, + targetRevision: true, + cutTick: true, + cutWallAt: true, + resumeWallAt: true, + shiftTicks: true, + alignedTick: true, + }, + }), + db.prisma.$queryRaw>` + SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "dbNow" + `, + db.prisma.turnDaemonLease.findMany({ orderBy: { profile: 'asc' } }), + ]); + log('database-status', { + world: { + ...world, + clockTick: world.clockTick?.toString() ?? null, + lastTurnTick: world.lastTurnTick?.toString() ?? null, + clockRevision: world.clockRevision.toString(), + }, + humanGenerals, + humanGeneralDetails: humanGeneralDetails.map((general) => ({ + ...general, + turnTime: general.turnTime.toISOString(), + turnTick: general.turnTick?.toString() ?? null, + })), + allGenerals, + nations, + cities, + messages: messages.map((message) => ({ + id: message.id, + time: message.time.toISOString(), + timeTick: message.timeTick?.toString() ?? null, + text: message.text, + })), + monitorMessages: monitorMessages.reverse().map((message) => ({ + id: message.id, + time: message.time.toISOString(), + timeTick: message.timeTick?.toString() ?? null, + text: message.text, + })), + betting: betting.map((row) => ({ + id: row.id, + name: row.name, + finished: row.finished, + openYearMonth: row.openYearMonth, + closeYearMonth: row.closeYearMonth, + bets: row.bets.length, + })), + unification, + unificationActions: unificationActions.map((action) => ({ + messageId: action.messageId, + status: action.status, + createdGameTick: action.createdGameTick.toString(), + expiresGameTick: action.expiresGameTick?.toString() ?? null, + resolvedGameTick: action.resolvedGameTick?.toString() ?? null, + clockRevision: action.clockRevision.toString(), + deadlineGeneration: action.deadlineGeneration.toString(), + message: { + ...action.message, + createdAtWall: action.message.createdAtWall.toISOString(), + occurredGameTick: action.message.occurredGameTick?.toString() ?? null, + }, + })), + dbWallNow: dbWallRows[0]?.dbNow.toISOString() ?? null, + daemonLeases: daemonLeases.map((lease) => ({ + ...lease, + leaseUntil: lease.leaseUntil.toISOString(), + heartbeatAt: lease.heartbeatAt.toISOString(), + fencingEpoch: lease.fencingEpoch.toString(), + })), + suspensions: suspensions.map((row) => ({ + ...row, + sourceRevision: row.sourceRevision.toString(), + targetRevision: row.targetRevision.toString(), + cutTick: row.cutTick.toString(), + cutWallAt: row.cutWallAt.toISOString(), + resumeWallAt: row.resumeWallAt?.toISOString() ?? null, + shiftTicks: row.shiftTicks?.toString() ?? null, + alignedTick: row.alignedTick?.toString() ?? null, + })), + }); + } finally { + await db.disconnect(); + } +}; + +const preparePausedBetting = async (): Promise => { + const state = await readState(); + if (state.pausedBettingId) throw new Error(`Paused betting fixture already exists: ${state.pausedBettingId}`); + if (state.users?.length !== 10) throw new Error('Exactly ten lifecycle users are required.'); + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const world = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (world.clockPhase !== 'SUSPENDED') { + throw new Error(`Refusing paused betting fixture while clock phase is ${world.clockPhase}.`); + } + const nations = await db.prisma.nation.findMany({ + where: { id: { gt: 0 }, level: { gt: 0 } }, + orderBy: [{ level: 'desc' }, { id: 'asc' }], + take: 3, + }); + if (nations.length < 2) throw new Error(`At least two active nations are required, found ${nations.length}.`); + const general = await db.prisma.general.findFirstOrThrow({ + where: { name: state.users[0]!.generalName, userId: { not: null } }, + }); + if (!general.userId) throw new Error('Lifecycle user general has no user ID.'); + const aggregate = await db.prisma.nationBetting.aggregate({ _max: { id: true } }); + const bettingId = Math.max(900_000_000, (aggregate._max.id ?? 0) + 1); + const openYearMonth = world.currentYear * 12 + world.currentMonth - 1; + const candidates = await Promise.all( + nations.map(async (nation) => { + const [generalCount, cityCount] = await Promise.all([ + db.prisma.general.count({ where: { nationId: nation.id } }), + db.prisma.city.count({ where: { nationId: nation.id } }), + ]); + return { + title: nation.name, + info: `국력: ${nation.level}
장수 수: ${generalCount}
도시 수: ${cityCount}`, + isHtml: true, + aux: { + nation: nation.id, + name: nation.name, + color: nation.color, + type: nation.typeCode, + level: nation.level, + capital: nation.capitalCityId, + gennum: generalCount, + power: nation.level, + city_cnt: cityCount, + }, + }; + }) + ); + await db.prisma.$transaction([ + db.prisma.nationBetting.create({ + data: { + id: bettingId, + type: 'bettingNation', + name: `일시 중지 실제 제출 검증 ${state.runId.slice(0, 8)}`, + finished: false, + selectCount: 1, + isExclusive: null, + requiresInheritancePoint: true, + openYearMonth, + closeYearMonth: openYearMonth + 12, + candidates, + }, + }), + db.prisma.inheritancePoint.upsert({ + where: { userId_key: { userId: general.userId, key: 'previous' } }, + update: { value: 1000 }, + create: { userId: general.userId, key: 'previous', value: 1000 }, + }), + ]); + state.pausedBettingId = bettingId; + await writeState(state); + log('paused-betting-fixture-prepared', { + bettingId, + clockPhase: world.clockPhase, + year: world.currentYear, + month: world.currentMonth, + candidates: candidates.map((candidate) => candidate.title), + userGeneral: general.name, + fixtureOnly: ['nation_betting', 'inheritance_point'], + }); + } finally { + await db.disconnect(); + } +}; + +const submitPausedBetting = async (): Promise => { + const state = await readState(); + const bettingId = state.pausedBettingId; + if (!bettingId || state.users?.length !== 10) throw new Error('Paused betting fixture and users are required.'); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 500, height: 844 } }); + const user = state.users[0]!; + await context.addInitScript( + ({ gatewayToken, gameToken, profile }) => { + window.localStorage.setItem('sammo-session-token', gatewayToken); + window.localStorage.setItem('sammo-game-token', gameToken); + window.localStorage.setItem('sammo-game-profile', profile); + }, + { gatewayToken: user.gatewayToken, gameToken: user.gameToken, profile: profileName } + ); + const page = await context.newPage(); + let browserErrors = 0; + let applicationHttpErrors = 0; + page.on('pageerror', () => { + browserErrors += 1; + }); + page.on('response', (response) => { + if (response.status() >= 400 && !new URL(response.url()).pathname.startsWith('/image/')) { + applicationHttpErrors += 1; + } + }); + try { + const before = await createGame(user.gameToken).betting.getDetail.query({ bettingId }); + await page.goto(`${webOrigin}/hwe/nation-betting`, { waitUntil: 'networkidle' }); + await page + .getByRole('button', { name: new RegExp(`일시 중지 실제 제출 검증 ${state.runId.slice(0, 8)}`) }) + .click(); + await page.locator('.betting-candidate').first().click(); + await page.getByRole('spinbutton', { name: '베팅 금액' }).fill('100'); + const wallSubmittedAt = new Date().toISOString(); + await page.getByRole('button', { name: '베팅', exact: true }).click(); + await page.getByTestId('game-toast').getByText('베팅했습니다', { exact: true }).waitFor({ state: 'visible' }); + await page.screenshot({ path: path.join(artifactDir, 'paused-betting-submitted.png'), fullPage: true }); + const after = await createGame(user.gameToken).betting.getDetail.query({ bettingId }); + log('paused-betting-submitted', { + bettingId, + wallSubmittedAt, + before: { remainPoint: before.remainPoint, myBetting: before.myBetting }, + after: { remainPoint: after.remainPoint, myBetting: after.myBetting }, + browserErrors, + applicationHttpErrors, + }); + if ( + after.remainPoint !== before.remainPoint - 100 || + !after.myBetting.some(([selection, amount]) => selection === '[0]' && amount === 100) || + browserErrors || + applicationHttpErrors + ) { + process.exitCode = 1; + } + } finally { + await context.close(); + await browser.close(); + } +}; + +const reserveUserEnlistments = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10) throw new Error('Exactly ten lifecycle users are required.'); + const nations = (await createGame(state.users[0]!.gameToken).public.getNationList.query()).filter( + (nation) => nation.id > 0 && nation.level > 0 + ); + if (nations.length === 0) throw new Error('No active nation exists for enlistment.'); + const reservations: Array<{ index: number; generalId: number; nationId: number; nationName: string }> = []; + for (const [offset, user] of state.users.entries()) { + const game = createGame(user.gameToken); + const generalResult = await game.general.me.query(); + if (!generalResult?.general) throw new Error(`Lifecycle user ${offset + 1} has no general.`); + if (generalResult.general.nationId > 0) { + log('user-enlistment-already-complete', { + index: offset + 1, + generalId: generalResult.general.id, + nationId: generalResult.general.nationId, + }); + continue; + } + const target = nations[offset % nations.length]!; + let snapshot = await game.turns.reserved.getGeneral.query({ generalId: generalResult.general.id }); + for (let turnIndex = 0; turnIndex < 10; turnIndex += 1) { + snapshot = await game.turns.reserved.setGeneral.mutate({ + generalId: generalResult.general.id, + turnIndex, + action: 'che_임관', + args: { destNationId: target.id }, + expectedRevision: snapshot.revision, + }); + } + reservations.push({ + index: offset + 1, + generalId: generalResult.general.id, + nationId: target.id, + nationName: target.name, + }); + } + log('user-enlistments-reserved', { reservations }); +}; + +const prepareActionFixture = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10) throw new Error('Exactly ten lifecycle users are required.'); + if (state.actionFixture) throw new Error('Action fixture was already prepared.'); + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const fixture = await db.prisma.$transaction(async (tx) => { + const world = await tx.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (world.clockPhase !== 'SUSPENDED') { + throw new Error( + `Action fixture requires a stopped or paused SUSPENDED clock, found ${world.clockPhase}.` + ); + } + const nations = await tx.nation.findMany({ + where: { id: { gt: 0 }, level: { gt: 0 } }, + orderBy: [{ level: 'desc' }, { id: 'asc' }], + take: 2, + }); + if (nations.length !== 2) + throw new Error(`Action fixture requires two active nations, found ${nations.length}.`); + const generalRows = await tx.general.findMany({ + where: { name: { in: state.users!.map((user) => user.generalName) }, userId: { not: null } }, + orderBy: { id: 'asc' }, + }); + if (generalRows.length !== 10) { + throw new Error(`Action fixture requires ten persisted user generals, found ${generalRows.length}.`); + } + const generalByName = new Map(generalRows.map((general) => [general.name, general])); + const resolvedNations = await Promise.all( + nations.map(async (nation) => { + const city = nation.capitalCityId + ? await tx.city.findUnique({ where: { id: nation.capitalCityId } }) + : await tx.city.findFirst({ where: { nationId: nation.id }, orderBy: { id: 'asc' } }); + if (!city) throw new Error(`Nation ${nation.id} has no fixture city.`); + return { nation, city }; + }) + ); + await tx.general.updateMany({ + where: { nationId: { in: nations.map((nation) => nation.id) }, officerLevel: { gte: 10 } }, + data: { officerLevel: 5 }, + }); + const placements: NonNullable['generals'] = []; + const officerLevels = [12, 11, 5, 1, 1] as const; + for (const [offset, user] of state.users!.entries()) { + const general = generalByName.get(user.generalName); + if (!general) throw new Error(`Lifecycle general is missing: ${user.generalName}`); + const nationIndex = offset < 5 ? 0 : 1; + const memberIndex = offset % 5; + const target = resolvedNations[nationIndex]!; + const updated = await tx.general.update({ + where: { id: general.id }, + data: { + nationId: target.nation.id, + cityId: target.city.id, + officerLevel: officerLevels[memberIndex]!, + gold: 50_000, + rice: 50_000, + ...(memberIndex === 2 ? { weaponCode: 'che_무기_01_단도' } : {}), + }, + select: { id: true, nationId: true, cityId: true, officerLevel: true }, + }); + if (!general.userId) throw new Error(`Lifecycle general ${general.id} has no user ID.`); + await tx.inheritancePoint.upsert({ + where: { userId_key: { userId: general.userId, key: 'previous' } }, + update: { value: 5_000 }, + create: { userId: general.userId, key: 'previous', value: 5_000 }, + }); + placements.push({ index: offset + 1, ...updated }); + } + for (const [nationIndex, target] of resolvedNations.entries()) { + const ruler = placements[nationIndex * 5]!; + await tx.nation.update({ + where: { id: target.nation.id }, + data: { chiefGeneralId: ruler.id, gold: 1_000_000, rice: 1_000_000 }, + }); + } + return { + clockPhase: world.clockPhase, + nations: resolvedNations.map(({ nation, city }) => ({ + id: nation.id, + name: nation.name, + capitalCityId: city.id, + })), + generals: placements, + }; + }); + state.actionFixture = { nations: fixture.nations, generals: fixture.generals }; + await writeState(state); + log('action-fixture-prepared', { + ...fixture, + fixtureOnly: [ + 'general.nation_id/city_id/officer_level/resources/weapon_code', + 'nation.chief_general_id/resources', + 'inheritance_point.previous', + ], + }); + } finally { + await db.disconnect(); + } +}; + +const rotateFirst = (values: readonly T[]): T[] => + values.length < 2 ? [...values] : [...values.slice(1), values[0]!]; + +const readHiddenBuffLevel = (rawMeta: unknown, key: string): number => { + if (!rawMeta || typeof rawMeta !== 'object' || Array.isArray(rawMeta)) return 0; + const rawBuff = Reflect.get(rawMeta, 'inheritBuff'); + let buff: unknown = rawBuff; + if (typeof rawBuff === 'string') { + try { + buff = JSON.parse(rawBuff) as unknown; + } catch { + return 0; + } + } + if (!buff || typeof buff !== 'object' || Array.isArray(buff)) return 0; + const value = Reflect.get(buff, key); + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +}; + +const repairActionItemFixture = async (): Promise => { + const state = await readState(); + if (!state.actionFixture) throw new Error('The action fixture is required.'); + const gateway = await loginAdmin(); + const profiles = (await gateway.admin.profiles.list.query()) as unknown as Array<{ + profileName: string; + status: string; + }>; + const profile = profiles.find((entry) => entry.profileName === profileName); + if (profile?.status !== 'STOPPED') { + throw new Error(`Item fixture repair requires STOPPED, found ${profile?.status ?? 'missing'}.`); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const targetGeneralId = state.actionFixture.generals[2]!.id; + const repaired = await db.prisma.$transaction(async (tx) => { + const world = await tx.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (world.clockPhase !== 'SUSPENDED') { + throw new Error(`Item fixture repair requires SUSPENDED, found ${world.clockPhase}.`); + } + const general = await tx.general.findUniqueOrThrow({ where: { id: targetGeneralId } }); + const meta = + general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta) + ? { ...general.meta } + : {}; + delete meta.itemInventory; + return tx.general.update({ + where: { id: targetGeneralId }, + data: { + weaponCode: 'che_무기_01_단도', + meta: meta as GamePrisma.InputJsonValue, + }, + select: { id: true, weaponCode: true }, + }); + }); + log('action-item-fixture-repaired', repaired); + } finally { + await db.disconnect(); + } +}; + +const exercisePausedActions = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture) { + throw new Error('Ten users and the action fixture are required.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const worldBefore = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (worldBefore.clockPhase !== 'SUSPENDED') { + throw new Error(`Paused action matrix requires SUSPENDED, found ${worldBefore.clockPhase}.`); + } + if (worldBefore.clockTick === null) + throw new Error('Paused action matrix requires an authoritative clock tick.'); + const clients = state.users.map((user) => createGame(user.gameToken)); + const generals = state.actionFixture.generals; + const firstNation = state.actionFixture.nations[0]!; + const secondNation = state.actionFixture.nations[1]!; + const runSlug = state.runId.replaceAll('-', '').slice(0, 8); + + const publicMessage = await clients[0]!.messages.send.mutate({ + generalId: generals[0]!.id, + mailbox: 9999, + text: `PAUSED-PUBLIC-${runSlug}`, + }); + const privateMessage = await clients[1]!.messages.send.mutate({ + generalId: generals[1]!.id, + mailbox: generals[2]!.id, + text: `PAUSED-PRIVATE-${runSlug}`, + }); + await clients[2]!.messages.readLatest.mutate({ + generalId: generals[2]!.id, + type: 'private', + messageId: privateMessage.msgId, + }); + const nationalMessage = await clients[0]!.messages.send.mutate({ + generalId: generals[0]!.id, + mailbox: 9000 + firstNation.id, + text: `PAUSED-NATIONAL-${runSlug}`, + }); + const deleteMessage = await clients[3]!.messages.send.mutate({ + generalId: generals[3]!.id, + mailbox: 9999, + text: `PAUSED-DELETE-NOW-${runSlug}`, + }); + const deleteResult = await clients[3]!.messages.delete.mutate({ + generalId: generals[3]!.id, + messageId: deleteMessage.msgId, + }); + const expiryMessage = await clients[4]!.messages.send.mutate({ + generalId: generals[4]!.id, + mailbox: 9999, + text: `PAUSED-DELETE-AFTER-FIVE-${runSlug}`, + }); + + let turnSnapshot = await clients[4]!.turns.reserved.getGeneral.query({ generalId: generals[4]!.id }); + turnSnapshot = await clients[4]!.turns.reserved.setGeneral.mutate({ + generalId: generals[4]!.id, + turnIndex: 0, + action: '휴식', + args: {}, + expectedRevision: turnSnapshot.revision, + }); + + const inheritanceRows = await db.prisma.general.findMany({ + where: { id: { in: generals.map(({ id }) => id) } }, + select: { id: true, meta: true }, + }); + const inheritanceById = new Map(inheritanceRows.map((general) => [general.id, general.meta])); + const inheritanceGeneralIndex = generals.findIndex( + (general, index) => index >= 3 && readHiddenBuffLevel(inheritanceById.get(general.id), 'warAvoidRatio') < 1 + ); + if (inheritanceGeneralIndex < 0) throw new Error('No lifecycle user remains for the inheritance purchase.'); + const inheritance = await clients[inheritanceGeneralIndex]!.inherit.buyHiddenBuff.mutate({ + type: 'warAvoidRatio', + level: 1, + }); + const permission = await clients[0]!.nation.changePermission.mutate({ + isAmbassador: true, + targetGeneralIds: [generals[2]!.id], + }); + const appointment = await clients[0]!.nation.appoint.mutate({ + destGeneralId: generals[4]!.id, + destCityId: firstNation.capitalCityId, + officerLevel: 2, + }); + const rate = await clients[0]!.nation.setRate.mutate({ amount: 20 }); + const bill = await clients[0]!.nation.setBill.mutate({ amount: 100 }); + const blockWar = await clients[0]!.nation.setBlockWar.mutate({ value: true }); + + const npcPolicy = await clients[0]!.npc.getPolicy.query(); + const npcPolicyMutation = await clients[0]!.npc.setNationPolicy.mutate({ + reqNationGold: npcPolicy.currentNationPolicy.reqNationGold + 100, + }); + const npcNationPriority = await clients[0]!.npc.setNationPriority.mutate( + rotateFirst(npcPolicy.currentNationPriority) + ); + const npcGeneralPriority = await clients[0]!.npc.setGeneralPriority.mutate( + rotateFirst(npcPolicy.currentGeneralActionPriority) + ); + + const letter = await clients[0]!.diplomacy.sendLetter.mutate({ + destNationId: secondNation.id, + brief: `PAUSED 외교문서 ${runSlug}`, + detail: `SUSPENDED 상태의 WALL_TIME 외교문서 ${runSlug}`, + }); + const letterResponse = await clients[5]!.diplomacy.respondLetter.mutate({ letterId: letter.id, agree: true }); + const dropped = await clients[2]!.general.dropItem.mutate({ itemType: 'weapon' }); + + const [worldAfter, persistedGenerals, persistedMessages, persistedLetter, inheritanceLogs, pendingEvents] = + await Promise.all([ + db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }), + db.prisma.general.findMany({ + where: { id: { in: [generals[2]!.id, generals[4]!.id] } }, + select: { + id: true, + nationId: true, + cityId: true, + officerLevel: true, + weaponCode: true, + meta: true, + }, + orderBy: { id: 'asc' }, + }), + db.prisma.message.findMany({ + where: { + id: { + in: [publicMessage.msgId, privateMessage.msgId, nationalMessage.msgId, expiryMessage.msgId], + }, + }, + select: { + id: true, + time: true, + timeTick: true, + createdAtWall: true, + deleteUntilWall: true, + tombstonedAtWall: true, + message: true, + }, + orderBy: { id: 'asc' }, + }), + db.prisma.diplomacyLetter.findUniqueOrThrow({ where: { id: letter.id } }), + db.prisma.inheritanceLog.findMany({ + where: { text: { contains: '회피' } }, + orderBy: { id: 'desc' }, + take: 5, + select: { id: true, userId: true, createdAt: true, year: true, month: true, text: true }, + }), + db.prisma.inputEvent.count({ where: { status: { in: ['PENDING', 'PROCESSING'] } } }), + ]); + if (worldAfter.clockTick === null) throw new Error('Paused action matrix lost the authoritative clock tick.'); + if (worldAfter.clockTick !== worldBefore.clockTick) { + throw new Error( + `Game tick moved during paused actions: ${worldBefore.clockTick} -> ${worldAfter.clockTick}.` + ); + } + if (persistedMessages.some((message) => message.timeTick !== null)) { + throw new Error('A paused ordinary message unexpectedly persisted a GAME_TIME occurrence tick.'); + } + if (!persistedGenerals.some((general) => general.id === generals[2]!.id && general.weaponCode === 'None')) { + throw new Error('Paused item discard did not persist the weapon removal.'); + } + if (!persistedGenerals.some((general) => general.id === generals[4]!.id && general.officerLevel === 2)) { + throw new Error('Paused personnel appointment did not persist.'); + } + const expiryRow = persistedMessages.find((message) => message.id === expiryMessage.msgId); + if (!expiryRow) throw new Error('Paused wall-expiry message was not persisted.'); + state.pausedWallExpiryMessage = { + generalIndex: 5, + messageId: expiryMessage.msgId, + createdAtWall: expiryRow.createdAtWall.toISOString(), + frozenGameTick: worldAfter.clockTick.toString(), + }; + await writeState(state); + log('paused-action-matrix-complete', { + clockTickBefore: worldBefore.clockTick.toString(), + clockTickAfter: worldAfter.clockTick.toString(), + publicMessage, + privateMessage, + nationalMessage, + deleteResult, + expiryMessage: state.pausedWallExpiryMessage, + turnRevision: turnSnapshot.revision, + inheritance, + dropped, + permission, + appointment, + rate, + bill, + blockWar, + npcPolicyMutation, + npcNationPriority, + npcGeneralPriority, + diplomacyLetterId: letter.id, + letterResponse, + persistedGenerals, + persistedMessages: persistedMessages.map((message) => ({ + id: message.id, + time: message.time.toISOString(), + timeTick: message.timeTick?.toString() ?? null, + createdAtWall: message.createdAtWall.toISOString(), + deleteUntilWall: message.deleteUntilWall.toISOString(), + tombstonedAtWall: message.tombstonedAtWall?.toISOString() ?? null, + })), + persistedLetter: { + id: persistedLetter.id, + date: persistedLetter.date.toISOString(), + state: persistedLetter.state, + }, + inheritanceLogs: inheritanceLogs.map((entry) => ({ + ...entry, + createdAt: entry.createdAt.toISOString(), + })), + pendingEvents, + }); + if (!deleteResult.deletedIds.includes(deleteMessage.msgId) || pendingEvents !== 0) process.exitCode = 1; + } finally { + await db.disconnect(); + } +}; + +const verifyPausedWallExpiry = async (): Promise => { + const state = await readState(); + const expiry = state.pausedWallExpiryMessage; + if (!expiry || state.users?.length !== 10 || !state.actionFixture) { + throw new Error('Paused wall-expiry message fixture is required.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const before = await db.prisma.message.findUniqueOrThrow({ + where: { id: expiry.messageId }, + select: { createdAtWall: true, deleteUntilWall: true, tombstonedAtWall: true }, + }); + const wallRows = await db.prisma.$queryRaw>` + SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS "nowWall" + `; + const nowWall = wallRows[0]?.nowWall; + if (!nowWall) throw new Error('DB wall clock query returned no row.'); + if (nowWall < before.deleteUntilWall) { + throw new Error( + `Wall delete window has not expired: ${nowWall.toISOString()} < ${before.deleteUntilWall.toISOString()}.` + ); + } + const general = state.actionFixture.generals[expiry.generalIndex - 1]!; + let rejectedMessage = ''; + try { + await createGame(state.users[expiry.generalIndex - 1]!.gameToken).messages.delete.mutate({ + generalId: general.id, + messageId: expiry.messageId, + }); + } catch (error) { + rejectedMessage = error instanceof Error ? error.message : String(error); + } + if (!rejectedMessage.includes('5분 이내')) { + throw new Error( + `Expired paused message was not rejected by the wall window: ${rejectedMessage || 'accepted'}` + ); + } + const [after, world] = await Promise.all([ + db.prisma.message.findUniqueOrThrow({ + where: { id: expiry.messageId }, + select: { tombstonedAtWall: true }, + }), + db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }), + ]); + if (after.tombstonedAtWall !== null) throw new Error('Expired message was unexpectedly tombstoned.'); + if (world.clockPhase !== 'SUSPENDED' || world.clockTick?.toString() !== expiry.frozenGameTick) { + throw new Error( + `Game clock changed during wall expiry: ${world.clockPhase}@${world.clockTick?.toString() ?? 'null'}.` + ); + } + log('paused-wall-expiry-verified', { + messageId: expiry.messageId, + createdAtWall: before.createdAtWall.toISOString(), + deleteUntilWall: before.deleteUntilWall.toISOString(), + dbNowWall: nowWall.toISOString(), + wallElapsedSeconds: Math.floor((nowWall.getTime() - before.createdAtWall.getTime()) / 1000), + frozenGameTick: expiry.frozenGameTick, + rejection: rejectedMessage, + }); + } finally { + await db.disconnect(); + } +}; + +const prepareTournamentBet = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture) { + throw new Error('Ten users and the action fixture are required.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const world = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (!['RUNNING', 'MANUAL'].includes(world.clockPhase) || world.clockTick === null || !world.clockWallAnchor) { + throw new Error(`Tournament setup requires a running authoritative game clock, found ${world.clockPhase}.`); + } + const bettor = state.actionFixture.generals[6]!; + const target = state.actionFixture.generals[7]!; + const opponent = state.actionFixture.generals[8]!; + const admin = await createAdminGame(); + const deadline = new Date(world.clockWallAnchor.getTime() + 60 * 60_000).toISOString(); + await admin.tournament.setParticipants.mutate([ + { id: target.id, name: state.users[7]!.generalName, leadership: 70, strength: 70, intel: 70, level: 1 }, + { id: opponent.id, name: state.users[8]!.generalName, leadership: 60, strength: 60, intel: 60, level: 1 }, + ]); + await admin.tournament.setMatches.mutate([ + { + id: 1, + stage: 7, + roundIndex: 0, + attackerId: target.id, + defenderId: opponent.id, + }, + ]); + await admin.tournament.setBettingEntries.mutate([]); + await admin.tournament.setState.mutate({ + stage: 6, + phase: 0, + type: 0, + auto: false, + openYear: world.currentYear, + openMonth: world.currentMonth, + termSeconds: 60, + nextAt: deadline, + bettingCloseAt: deadline, + bettingSettled: false, + rewardSettled: false, + }); + state.pausedTournamentBet = { + bettorGeneralId: bettor.id, + targetGeneralId: target.id, + preparedGameTick: world.clockTick.toString(), + }; + await writeState(state); + log('tournament-bet-prepared', { + bettorGeneralId: bettor.id, + targetGeneralId: target.id, + opponentGeneralId: opponent.id, + preparedGameTick: world.clockTick.toString(), + bettingCloseAt: deadline, + }); + } finally { + await db.disconnect(); + } +}; + +const submitPausedTournamentBet = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture || !state.pausedTournamentBet) { + throw new Error('A running-clock tournament fixture is required before the paused bet.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const world = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (world.clockPhase !== 'SUSPENDED' || world.clockTick === null) { + throw new Error(`Paused tournament betting requires SUSPENDED, found ${world.clockPhase}.`); + } + const bettor = state.actionFixture.generals[6]!; + const target = state.actionFixture.generals[7]!; + if ( + bettor.id !== state.pausedTournamentBet.bettorGeneralId || + target.id !== state.pausedTournamentBet.targetGeneralId + ) { + throw new Error('Tournament bettor fixture no longer matches the persisted lifecycle state.'); + } + const before = await db.prisma.general.findUniqueOrThrow({ where: { id: bettor.id }, select: { gold: true } }); + const result = await createGame(state.users[6]!.gameToken).tournament.placeBet.mutate({ + targetId: target.id, + amount: 100, + }); + const [after, snapshot, worldAfter] = await Promise.all([ + db.prisma.general.findUniqueOrThrow({ where: { id: bettor.id }, select: { gold: true } }), + createGame(state.users[6]!.gameToken).tournament.getSnapshot.query(), + db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }), + ]); + if (after.gold !== before.gold - 100 || snapshot.betCount !== 1) { + throw new Error( + `Paused tournament bet did not persist exactly once: gold ${before.gold}->${after.gold}, bets ${snapshot.betCount}.` + ); + } + if (worldAfter.clockPhase !== 'SUSPENDED' || worldAfter.clockTick !== world.clockTick) { + throw new Error('Game clock moved while submitting the paused tournament bet.'); + } + log('paused-tournament-bet-complete', { + bettorGeneralId: bettor.id, + targetGeneralId: target.id, + amount: 100, + goldBefore: before.gold, + goldAfter: after.gold, + betCount: snapshot.betCount, + clockTick: world.clockTick.toString(), + preparedGameTick: state.pausedTournamentBet.preparedGameTick, + result, + }); + } finally { + await db.disconnect(); + } +}; + +const reserveActionableCommands = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture) { + throw new Error('Ten users and the action fixture are required.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const world = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + const actor = state.actionFixture.generals[0]!; + const target = state.actionFixture.generals[9]!; + const destNation = state.actionFixture.nations[1]!; + const client = createGame(state.users[0]!.gameToken); + let generalTurns = await client.turns.reserved.getGeneral.query({ generalId: actor.id }); + generalTurns = await client.turns.reserved.setGeneral.mutate({ + generalId: actor.id, + turnIndex: 0, + action: 'che_등용', + args: { destGeneralId: target.id }, + expectedRevision: generalTurns.revision, + }); + let nationTurns = await client.turns.reserved.getNation.query({ generalId: actor.id }); + nationTurns = await client.turns.reserved.setNation.mutate({ + generalId: actor.id, + turnIndex: 0, + action: 'che_불가침제의', + args: { + destNationId: destNation.id, + year: world.currentYear + 1, + month: world.currentMonth, + }, + expectedRevision: nationTurns.revision, + }); + log('actionable-commands-reserved', { + clockPhase: world.clockPhase, + actorGeneralId: actor.id, + recruitmentTargetGeneralId: target.id, + noAggressionTargetNationId: destNation.id, + generalTurnRevision: generalTurns.revision, + nationTurnRevision: nationTurns.revision, + }); + } finally { + await db.disconnect(); + } +}; + +const waitActionableMessages = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture) { + throw new Error('Ten users and the action fixture are required.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const targetGeneral = state.actionFixture.generals[9]!; + const targetNation = state.actionFixture.nations[1]!; + const deadline = Date.now() + Number(process.env.SAMMO_LIVE_ACTIONABLE_TIMEOUT_MS ?? '300000'); + let recruitmentId: number | undefined; + let noAggressionId: number | undefined; + while (Date.now() < deadline && (!recruitmentId || !noAggressionId)) { + const actions = await db.prisma.messageAction.findMany({ + where: { actionType: { in: ['scout', 'noAggression'] }, status: 'PENDING' }, + orderBy: { createdAtWall: 'desc' }, + include: { + message: { + select: { + id: true, + mailbox: true, + type: true, + createdAtWall: true, + occurredGameTick: true, + }, + }, + }, + }); + recruitmentId = actions.find( + (action) => action.actionType === 'scout' && action.message.mailbox === targetGeneral.id + )?.messageId; + noAggressionId = actions.find( + (action) => action.actionType === 'noAggression' && action.message.mailbox === 9000 + targetNation.id + )?.messageId; + if (!recruitmentId || !noAggressionId) await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + if (!recruitmentId || !noAggressionId) { + throw new Error( + `Actionable messages were not both delivered: recruitment=${recruitmentId ?? 'missing'}, noAggression=${noAggressionId ?? 'missing'}.` + ); + } + const [recruitmentInbox, diplomacyInbox, actions] = await Promise.all([ + createGame(state.users[9]!.gameToken).messages.getRecent.query({ generalId: targetGeneral.id }), + createGame(state.users[5]!.gameToken).messages.getRecent.query({ + generalId: state.actionFixture.generals[5]!.id, + }), + db.prisma.messageAction.findMany({ + where: { messageId: { in: [recruitmentId, noAggressionId] } }, + orderBy: { messageId: 'asc' }, + }), + ]); + if (!recruitmentInbox.private.some((message) => message.id === recruitmentId)) { + throw new Error('Recruitment recipient did not receive the actionable private message.'); + } + if (!diplomacyInbox.diplomacy.some((message) => message.id === noAggressionId)) { + throw new Error('Nation ruler did not receive the actionable diplomacy message.'); + } + const noAggressionAction = actions.find((action) => action.actionType === 'noAggression'); + if (!noAggressionAction || noAggressionAction.expiresGameTick === null) { + throw new Error('The time-limited no-aggression proposal is missing its authoritative GAME_TIME deadline.'); + } + state.actionableMessages = { recruitmentId, noAggressionId }; + await writeState(state); + log('actionable-messages-received', { + recruitmentId, + noAggressionId, + actions: actions.map((action) => ({ + messageId: action.messageId, + actionType: action.actionType, + status: action.status, + createdGameTick: action.createdGameTick.toString(), + expiresGameTick: action.expiresGameTick?.toString() ?? null, + deadlinePolicy: action.expiresGameTick === null ? 'INDEFINITE_GAME_ACTION' : 'GAME_TIME', + clockRevision: action.clockRevision.toString(), + deadlineGeneration: action.deadlineGeneration.toString(), + })), + }); + } finally { + await db.disconnect(); + } +}; + +const respondActionableMessages = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture || !state.actionableMessages) { + throw new Error('Received actionable message state is required.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const worldBefore = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (!['RUNNING', 'MANUAL'].includes(worldBefore.clockPhase) || worldBefore.clockTick === null) { + throw new Error(`Actionable response requires a running game clock, found ${worldBefore.clockPhase}.`); + } + const recruitmentTarget = state.actionFixture.generals[9]!; + const diplomacyActor = state.actionFixture.generals[5]!; + const actionsBefore = await db.prisma.messageAction.findMany({ + where: { messageId: { in: Object.values(state.actionableMessages) } }, + }); + const recruitment = + actionsBefore.find((action) => action.messageId === state.actionableMessages!.recruitmentId)?.status === + 'PENDING' + ? await createGame(state.users[9]!.gameToken).messages.respond.mutate({ + generalId: recruitmentTarget.id, + messageId: state.actionableMessages.recruitmentId, + // A general who is already serving another nation can receive the + // recruitment letter but cannot accept it without first becoming + // unaffiliated. Decline it so this lifecycle keeps every user in a + // nation while still exercising the actionable ENGINE response. + response: false, + }) + : { skipped: 'already resolved' }; + const noAggression = + actionsBefore.find((action) => action.messageId === state.actionableMessages!.noAggressionId)?.status === + 'PENDING' + ? await createGame(state.users[5]!.gameToken).messages.respond.mutate({ + generalId: diplomacyActor.id, + messageId: state.actionableMessages.noAggressionId, + response: true, + }) + : { skipped: 'already resolved' }; + const [targetAfter, actionsAfter, diplomacyRows, worldAfter] = await Promise.all([ + db.prisma.general.findUniqueOrThrow({ where: { id: recruitmentTarget.id } }), + db.prisma.messageAction.findMany({ + where: { messageId: { in: Object.values(state.actionableMessages) } }, + orderBy: { messageId: 'asc' }, + }), + db.prisma.diplomacy.findMany({ + where: { + srcNationId: { in: state.actionFixture.nations.map((nation) => nation.id) }, + destNationId: { in: state.actionFixture.nations.map((nation) => nation.id) }, + }, + orderBy: { id: 'asc' }, + }), + db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }), + ]); + if (targetAfter.nationId !== state.actionFixture.nations[1]!.id) { + throw new Error(`Recruitment response left target in nation ${targetAfter.nationId}.`); + } + if (actionsAfter.some((action) => action.status !== 'RESOLVED' || action.resolvedGameTick === null)) { + throw new Error('An actionable message was not resolved with an authoritative game tick.'); + } + log('actionable-responses-complete', { + responseGameTick: worldAfter.clockTick?.toString() ?? null, + recruitment, + noAggression, + recruitmentTarget: { + id: targetAfter.id, + nationId: targetAfter.nationId, + officerLevel: targetAfter.officerLevel, + }, + actions: actionsAfter.map((action) => ({ + messageId: action.messageId, + actionType: action.actionType, + status: action.status, + resolvedGameTick: action.resolvedGameTick?.toString() ?? null, + })), + diplomacyRows: diplomacyRows.map((row) => ({ + srcNationId: row.srcNationId, + destNationId: row.destNationId, + stateCode: row.stateCode, + term: row.term, + })), + }); + } finally { + await db.disconnect(); + } +}; + +const reserveNoAggressionCancellation = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture) { + throw new Error('Ten users and the action fixture are required.'); + } + const actor = state.actionFixture.generals[0]!; + const destNation = state.actionFixture.nations[1]!; + const client = createGame(state.users[0]!.gameToken); + let snapshot = await client.turns.reserved.getNation.query({ generalId: actor.id }); + snapshot = await client.turns.reserved.setNation.mutate({ + generalId: actor.id, + turnIndex: 0, + action: 'che_불가침파기제의', + args: { destNationId: destNation.id }, + expectedRevision: snapshot.revision, + }); + log('no-aggression-cancellation-reserved', { + actorGeneralId: actor.id, + destNationId: destNation.id, + revision: snapshot.revision, + }); +}; + +const waitNoAggressionCancellation = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture) { + throw new Error('Ten users and the action fixture are required.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const targetNation = state.actionFixture.nations[1]!; + const deadline = Date.now() + Number(process.env.SAMMO_LIVE_ACTIONABLE_TIMEOUT_MS ?? '300000'); + let action: + (Awaited> & { message: { mailbox: number } }) | null = + null; + while (Date.now() < deadline && !action) { + action = await db.prisma.messageAction.findFirst({ + where: { + actionType: 'cancelNA', + status: 'PENDING', + message: { mailbox: 9000 + targetNation.id }, + }, + orderBy: { createdAtWall: 'desc' }, + include: { message: { select: { mailbox: true } } }, + }); + if (!action) await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + if (!action) throw new Error('Non-aggression cancellation message was not delivered.'); + state.cancelNoAggressionMessageId = action.messageId; + await writeState(state); + log('no-aggression-cancellation-received', { + messageId: action.messageId, + createdGameTick: action.createdGameTick.toString(), + expiresGameTick: action.expiresGameTick?.toString() ?? null, + clockRevision: action.clockRevision.toString(), + deadlineGeneration: action.deadlineGeneration.toString(), + }); + } finally { + await db.disconnect(); + } +}; + +const respondNoAggressionCancellation = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture || !state.cancelNoAggressionMessageId) { + throw new Error('Received non-aggression cancellation message is required.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const worldBefore = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (!['RUNNING', 'MANUAL'].includes(worldBefore.clockPhase) || worldBefore.clockTick === null) { + throw new Error(`Cancellation response requires a running game clock, found ${worldBefore.clockPhase}.`); + } + const diplomacyActor = state.actionFixture.generals[5]!; + const result = await createGame(state.users[5]!.gameToken).messages.respond.mutate({ + generalId: diplomacyActor.id, + messageId: state.cancelNoAggressionMessageId, + response: true, + }); + const [action, diplomacyRows, worldAfter] = await Promise.all([ + db.prisma.messageAction.findUniqueOrThrow({ where: { messageId: state.cancelNoAggressionMessageId } }), + db.prisma.diplomacy.findMany({ + where: { + srcNationId: { in: state.actionFixture.nations.map((nation) => nation.id) }, + destNationId: { in: state.actionFixture.nations.map((nation) => nation.id) }, + }, + orderBy: { id: 'asc' }, + }), + db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }), + ]); + if (action.status !== 'RESOLVED' || action.resolvedGameTick === null) { + throw new Error('Non-aggression cancellation action was not resolved.'); + } + if (diplomacyRows.some((row) => row.stateCode === 7)) { + throw new Error('Accepted cancellation left a non-aggression relation active.'); + } + log('no-aggression-cancellation-complete', { + messageId: state.cancelNoAggressionMessageId, + result, + resolvedGameTick: action.resolvedGameTick.toString(), + diplomacyRows: diplomacyRows.map((row) => ({ + srcNationId: row.srcNationId, + destNationId: row.destNationId, + stateCode: row.stateCode, + term: row.term, + })), + responseGameTick: worldAfter.clockTick?.toString() ?? null, + }); + } finally { + await db.disconnect(); + } +}; + +const npcActionAudit = async (): Promise => { + const label = process.argv[3]?.trim() || 'checkpoint'; + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const [world, population, topActions, recentActions, errors, failedInputEvents] = await Promise.all([ + db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }), + db.prisma.$queryRaw< + Array<{ + npcState: number; + generals: bigint; + affiliated: bigint; + armed: bigint; + totalCrew: bigint; + }> + >` + SELECT + g.npc_state AS "npcState", + COUNT(*) AS generals, + COUNT(*) FILTER (WHERE g.nation_id > 0) AS affiliated, + COUNT(*) FILTER (WHERE g.crew > 0) AS armed, + COALESCE(SUM(g.crew), 0)::bigint AS "totalCrew" + FROM general g + WHERE g.npc_state >= 2 + GROUP BY g.npc_state + ORDER BY g.npc_state + `, + db.prisma.$queryRaw>` + SELECT + regexp_replace(l.text, '<[^>]+>', '', 'g') AS "actionText", + COUNT(*) AS executions + FROM log_entry l + JOIN general g ON g.id = l.general_id + WHERE g.npc_state >= 2 + AND l.category = 'ACTION'::"LogCategory" + GROUP BY regexp_replace(l.text, '<[^>]+>', '', 'g') + ORDER BY executions DESC, "actionText" ASC + LIMIT 40 + `, + db.prisma.$queryRaw< + Array<{ id: number; year: number; month: number; generalId: number; generalName: string; text: string }> + >` + SELECT + l.id, + l.year, + l.month, + l.general_id AS "generalId", + g.name AS "generalName", + regexp_replace(l.text, '<[^>]+>', '', 'g') AS text + FROM log_entry l + JOIN general g ON g.id = l.general_id + WHERE g.npc_state >= 2 + AND l.category = 'ACTION'::"LogCategory" + ORDER BY l.id DESC + LIMIT 40 + `, + db.prisma.errorLog.findMany({ + orderBy: { id: 'desc' }, + take: 30, + select: { id: true, category: true, source: true, message: true, createdAt: true }, + }), + db.prisma.inputEvent.findMany({ + where: { status: 'FAILED' }, + orderBy: { createdAt: 'desc' }, + take: 30, + select: { sequence: true, requestId: true, eventType: true, error: true, createdAt: true }, + }), + ]); + const activeNations = await db.prisma.nation.findMany({ + where: { id: { gt: 0 }, level: { gt: 0 } }, + orderBy: { id: 'asc' }, + }); + const activeNationIds = activeNations.map((nation) => nation.id); + const [activeNationStats, activeRulers, reservedNationActions, recentNationActions, activeDiplomacy] = + await Promise.all([ + db.prisma.$queryRaw< + Array<{ + nationId: number; + cities: bigint; + generals: bigint; + armedGenerals: bigint; + totalCrew: bigint; + }> + >` + SELECT + n.id AS "nationId", + (SELECT COUNT(*) FROM city c WHERE c.nation_id = n.id) AS cities, + (SELECT COUNT(*) FROM general g WHERE g.nation_id = n.id) AS generals, + (SELECT COUNT(*) FROM general g WHERE g.nation_id = n.id AND g.crew > 0) AS "armedGenerals", + (SELECT COALESCE(SUM(g.crew), 0)::bigint FROM general g WHERE g.nation_id = n.id) AS "totalCrew" + FROM nation n + WHERE n.id = ANY(${activeNationIds}::int[]) + ORDER BY n.id + `, + db.prisma.general.findMany({ + where: { nationId: { in: activeNationIds }, officerLevel: 12 }, + orderBy: [{ nationId: 'asc' }, { id: 'asc' }], + select: { id: true, name: true, nationId: true, npcState: true, crew: true, lastTurn: true }, + }), + db.prisma.$queryRaw>` + SELECT nation_id AS "nationId", action_code AS "actionCode", COUNT(*) AS reservations + FROM nation_turn + WHERE nation_id = ANY(${activeNationIds}::int[]) + GROUP BY nation_id, action_code + ORDER BY nation_id, reservations DESC, action_code + `, + db.prisma.logEntry.findMany({ + where: { nationId: { in: activeNationIds }, category: 'ACTION' }, + orderBy: { id: 'desc' }, + take: 80, + select: { id: true, year: true, month: true, nationId: true, generalId: true, text: true }, + }), + db.prisma.diplomacy.findMany({ + where: { srcNationId: { in: activeNationIds }, destNationId: { in: activeNationIds } }, + orderBy: [{ srcNationId: 'asc' }, { destNationId: 'asc' }], + select: { srcNationId: true, destNationId: true, stateCode: true, term: true, isDead: true }, + }), + ]); + const statByNation = new Map(activeNationStats.map((entry) => [entry.nationId, entry])); + log('npc-action-audit', { + label, + world: { + year: world.currentYear, + month: world.currentMonth, + clockPhase: world.clockPhase, + clockTick: world.clockTick?.toString() ?? null, + }, + population: population.map((row) => ({ + ...row, + generals: Number(row.generals), + affiliated: Number(row.affiliated), + armed: Number(row.armed), + totalCrew: Number(row.totalCrew), + })), + topExecutedActions: topActions.map((row) => ({ + text: row.actionText, + executions: Number(row.executions), + })), + recentExecutedActions: recentActions.reverse(), + errors: errors.map((error) => ({ ...error, createdAt: error.createdAt.toISOString() })), + failedInputEvents: failedInputEvents.map((event) => ({ + ...event, + sequence: event.sequence.toString(), + createdAt: event.createdAt.toISOString(), + })), + activeNationStrategy: activeNations.map((nation) => { + const stats = statByNation.get(nation.id); + return { + id: nation.id, + name: nation.name, + level: nation.level, + gold: nation.gold, + rice: nation.rice, + cities: Number(stats?.cities ?? 0), + generals: Number(stats?.generals ?? 0), + armedGenerals: Number(stats?.armedGenerals ?? 0), + totalCrew: Number(stats?.totalCrew ?? 0), + rulers: activeRulers.filter((general) => general.nationId === nation.id), + reservedNationActions: reservedNationActions + .filter((entry) => entry.nationId === nation.id) + .map((entry) => ({ actionCode: entry.actionCode, reservations: Number(entry.reservations) })), + }; + }), + activeDiplomacy, + recentNationActions: recentNationActions.reverse(), + }); + } finally { + await db.disconnect(); + } +}; + +const repairOpeningClockRuntime = async (): Promise => { + const gateway = await loginAdmin(); + const profiles = (await gateway.admin.profiles.list.query()) as unknown as Array<{ + profileName: string; + status: string; + openAt: string | null; + }>; + const profile = profiles.find((entry) => entry.profileName === profileName); + if (!profile) throw new Error(`Profile not found: ${profileName}`); + if (profile.status !== 'PREOPEN' || !profile.openAt || new Date(profile.openAt).getTime() > Date.now()) { + throw new Error(`Opening clock repair requires an overdue PREOPEN profile, found ${profile.status}.`); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + const redis = createRedisConnector({ url: redisUrl() }); + await db.connect(); + await redis.connect(); + try { + const [world, activeSuspensions, pendingOutboxes] = await Promise.all([ + db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }), + db.prisma.clockSuspension.count({ where: { status: { in: ['SUSPENDED', 'RECONCILING'] } } }), + db.prisma.clockProjectionOutbox.count({ where: { status: { in: ['PENDING', 'APPLYING'] } } }), + ]); + if ( + world.clockPhase !== 'RUNNING' || + world.clockTick !== 0n || + activeSuspensions !== 0 || + pendingOutboxes !== 0 + ) { + throw new Error( + `Refusing opening clock repair for ${world.clockPhase}@${world.clockTick?.toString() ?? 'null'} with ${activeSuspensions} suspensions and ${pendingOutboxes} outboxes.` + ); + } + const keys = [ + `sammo:${profileName}:clock:active-revision`, + `sammo:${profileName}:clock:deadline-generation`, + `sammo:${profileName}:clock:phase`, + ]; + const before = await Promise.all(keys.map((key) => redis.client.get(key))); + const result = await redis.client.eval( + ` + for index = 1, 3 do + local current = redis.call('GET', KEYS[index]) + if (current or '') ~= ARGV[index] then return 0 end + end + redis.call('SET', KEYS[1], ARGV[4]) + redis.call('SET', KEYS[2], ARGV[5]) + redis.call('SET', KEYS[3], 'RUNNING') + return 1 + `, + { + keys, + arguments: [ + before[0] ?? '', + before[1] ?? '', + before[2] ?? '', + world.clockRevision.toString(), + world.deadlineGeneration.toString(), + ], + } + ); + if (Number(result) !== 1) throw new Error('Redis clock authority changed during opening repair.'); + const after = await Promise.all(keys.map((key) => redis.client.get(key))); + log('opening-clock-runtime-repaired', { + profileStatus: profile.status, + worldClock: { + phase: world.clockPhase, + tick: world.clockTick.toString(), + revision: world.clockRevision.toString(), + deadlineGeneration: world.deadlineGeneration.toString(), + }, + redisBefore: before, + redisAfter: after, + activeSuspensions, + pendingOutboxes, + reason: 'stale season-owned Redis clock authority survived RESET', + }); + } finally { + await redis.disconnect(); + await db.disconnect(); + } +}; + +const verifyExistingMonitorMessage = async (): Promise => { + const opened = await openUserPages(); + const sequence = Number(process.argv[3] ?? '22'); + if (!Number.isInteger(sequence) || sequence < 1) throw new Error('Monitor message sequence must be positive.'); + const text = `MONITOR-${opened.state.runId.replaceAll('-', '').slice(0, 8)}-${String(sequence).padStart(2, '0')}`; + try { + await Promise.all( + opened.records.map(({ page }) => + page + .locator('.PublicTalk') + .getByText(text, { exact: true }) + .waitFor({ state: 'visible', timeout: 60_000 }) + ) + ); + await opened.records[0]!.page.screenshot({ + path: path.join(artifactDir, `monitor-recovered-${String(sequence).padStart(2, '0')}.png`), + fullPage: true, + }); + log('monitor-message-recovered-fanout', { text, viewersObserved: 10, ...opened.errors() }); + } finally { + await Promise.all(opened.records.map(({ context }) => context.close())); + await opened.browser.close(); + } +}; + +const resumeDaemon = async (): Promise => { + const game = await createAdminGame(); + const result = await game.turnDaemon.resume.mutate({ + reason: 'isolated lifecycle recovery after failed paused DELAY', + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + const after = await game.turnDaemon.status.query({ timeoutMs: 1000 }); + log('turn-daemon-resumed-directly', { requestId: result.requestId, after }); +}; + +const fastForward = async (): Promise => { + const state = await readState(); + const maxMonths = Number(process.argv[3] ?? '1200'); + const stopNationCount = Number(process.env.SAMMO_FAST_FORWARD_STOP_NATIONS ?? '0'); + if (!Number.isInteger(maxMonths) || maxMonths < 1) throw new Error('fast-forward months must be positive.'); + if (!Number.isInteger(stopNationCount) || stopNationCount < 0) { + throw new Error('SAMMO_FAST_FORWARD_STOP_NATIONS must be a non-negative integer.'); + } + const clockDb = createGamePostgresConnector({ url: gameDatabaseUrl() }); + const clockRedis = createRedisConnector({ url: redisUrl() }); + await clockDb.connect(); + await clockRedis.connect(); + try { + const worldBefore = await clockDb.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + let reconciliation = null; + let projection: 'IDLE' | 'APPLIED' | 'RECOVERED' | null = null; + if (worldBefore.clockPhase === 'SUSPENDED' || worldBefore.clockPhase === 'RECONCILING') { + const suspension = await clockDb.prisma.clockSuspension.findFirstOrThrow({ + where: { status: { in: ['SUSPENDED', 'RECONCILING'] } }, + orderBy: { createdAt: 'desc' }, + }); + if (worldBefore.clockPhase === 'SUSPENDED') { + reconciliation = await reconcileClockSuspension({ + db: clockDb.prisma, + suspensionId: suspension.id, + authority: { + kind: 'OFFLINE', + profileName, + reason: 'exclusive ten-user lifecycle fast-forward', + }, + }); + } + projection = await applyNextClockProjection({ + db: clockDb.prisma, + redis: clockRedis.client, + workerId: `lifecycle-fast-forward:${state.runId.slice(0, 8)}`, + }); + if (projection === 'IDLE') { + throw new Error('Offline fast-forward reconciliation had no claimable clock projection.'); + } + } else if (!['RUNNING', 'MANUAL'].includes(worldBefore.clockPhase)) { + throw new Error(`Offline fast-forward cannot start from clock phase ${worldBefore.clockPhase}.`); + } + const worldAfter = await clockDb.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (!['RUNNING', 'MANUAL'].includes(worldAfter.clockPhase)) { + throw new Error(`Offline fast-forward clock preparation ended in ${worldAfter.clockPhase}.`); + } + log('fast-forward-clock-prepared', { + phaseBefore: worldBefore.clockPhase, + phaseAfter: worldAfter.clockPhase, + revisionBefore: worldBefore.clockRevision.toString(), + revisionAfter: worldAfter.clockRevision.toString(), + reconciliation, + projection, + }); + } finally { + await clockRedis.disconnect(); + await clockDb.disconnect(); + } + const runtime = await createTurnDaemonRuntime({ + profile: profileName.split(':', 1)[0] ?? 'hwe', + databaseUrl: gameDatabaseUrl(), + gameClockMode: 'manual', + leaseOwnerId: `lifecycle-fast-forward-${state.runId.slice(0, 8)}`, + enableLeaseHeartbeat: true, + leaseDurationMs: 300_000, + exclusiveFastForward: true, + databaseTransactionTimeoutMs: 300_000, + }); + const startedAt = Date.now(); + let months = 0; + try { + while (months < maxMonths) { + const before = runtime.world.getState(); + const targetTime = new Date(before.lastTurnTime.getTime() + before.tickSeconds * 1000); + // The production lifecycle advances the authoritative game clock + // before processing a requested target. This fixture drives the + // processor directly to reset memory between long batches, so it + // must preserve that same boundary explicitly. Otherwise monthly + // turns move while world_state.clock_tick stays behind, producing + // impossible action histories such as resolvedGameTick < + // createdGameTick at the unification/invader hand-off. + runtime.world.advanceGameClockTo(targetTime, new Date()); + let checkpoint; + do { + const result = await runtime.processor.run( + targetTime, + { budgetMs: 300_000, maxGenerals: 10_000, catchUpCap: 1 }, + checkpoint + ); + await runtime.hooks?.flushChanges?.(result); + checkpoint = result.checkpoint; + if (result.partial && result.processedGenerals === 0 && result.processedTurns === 0) { + throw new Error('Fast-forward made no progress within its budget.'); + } + } while (checkpoint); + months += 1; + const state = runtime.world.getState(); + const activeNations = runtime.world.listNations().filter((nation) => nation.id > 0 && nation.level > 0); + const rawUnited = Reflect.get(state.meta, 'isunited') ?? Reflect.get(state.meta, 'isUnited') ?? 0; + const isUnited = typeof rawUnited === 'number' ? rawUnited : Number(rawUnited); + if ( + months === 1 || + months % 12 === 0 || + activeNations.length <= Math.max(stopNationCount, 5) || + isUnited >= 2 + ) { + log('fast-forward-progress', { + months, + year: state.currentYear, + month: state.currentMonth, + activeNations: activeNations.length, + generals: runtime.world.listGenerals().length, + isUnited, + elapsedSeconds: Math.round((Date.now() - startedAt) / 1000), + }); + } + if (isUnited >= 2 || (stopNationCount > 0 && activeNations.length <= stopNationCount)) break; + } + const state = runtime.world.getState(); + log('fast-forward-complete', { + months, + year: state.currentYear, + month: state.currentMonth, + activeNations: runtime.world.listNations().filter((nation) => nation.id > 0 && nation.level > 0).length, + elapsedSeconds: Math.round((Date.now() - startedAt) / 1000), + }); + } finally { + await runtime.close(); + } +}; + +const placeInvaderRecipients = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10) throw new Error('Exactly ten lifecycle users are required.'); + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const nations = await db.prisma.nation.findMany({ + where: { id: { gt: 0 }, level: { gt: 0 } }, + orderBy: [{ level: 'desc' }, { id: 'asc' }], + take: 5, + }); + if (nations.length < 1 || nations.length > 5) { + throw new Error(`Recipient fixture requires one to five active nations, found ${nations.length}.`); + } + const placements = []; + for (const [index, user] of state.users.entries()) { + const nation = nations[index % nations.length]!; + const city = nation.capitalCityId + ? { id: nation.capitalCityId } + : await db.prisma.city.findFirstOrThrow({ where: { nationId: nation.id }, orderBy: { id: 'asc' } }); + const officerLevel = Math.min(11, 5 + Math.floor(index / nations.length)); + const general = await db.prisma.general.update({ + where: { id: (await db.prisma.general.findFirstOrThrow({ where: { name: user.generalName } })).id }, + data: { nationId: nation.id, cityId: city.id, officerLevel }, + select: { id: true, name: true, nationId: true, officerLevel: true, cityId: true }, + }); + placements.push({ ...general, nationName: nation.name }); + } + log('invader-recipient-fixture-placed', { + activeNations: nations.map((nation) => ({ id: nation.id, name: nation.name, level: nation.level })), + placements, + fixtureOnly: ['general.nation_id', 'general.city_id', 'general.officer_level'], + }); + } finally { + await db.disconnect(); + } +}; + +const respondToInvaderMessageInBrowser = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10) throw new Error('Exactly ten lifecycle users are required.'); + const messageId = Number(requiredEnv('SAMMO_INVADER_MESSAGE_ID')); + const userIndex = Number(requiredEnv('SAMMO_INVADER_USER_INDEX')); + if (!Number.isInteger(messageId) || messageId < 1) throw new Error('SAMMO_INVADER_MESSAGE_ID must be positive.'); + if (!Number.isInteger(userIndex) || userIndex < 1 || userIndex > state.users.length) { + throw new Error('SAMMO_INVADER_USER_INDEX must identify a lifecycle user.'); + } + const user = state.users[userIndex - 1]!; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + try { + await context.addInitScript( + ({ gatewayToken, gameToken, profile }) => { + window.localStorage.setItem('sammo-session-token', gatewayToken); + window.localStorage.setItem('sammo-game-token', gameToken); + window.localStorage.setItem('sammo-game-profile', profile); + }, + { gatewayToken: user.gatewayToken, gameToken: user.gameToken, profile: profileName } + ); + const page = await context.newPage(); + page.setDefaultTimeout(30_000); + let browserErrors = 0; + let applicationHttpErrors = 0; + page.on('pageerror', () => { + browserErrors += 1; + }); + page.on('response', (response) => { + if (response.status() >= 400 && !new URL(response.url()).pathname.startsWith('/image/')) { + applicationHttpErrors += 1; + } + }); + await page.goto(`${webOrigin}/hwe/`, { waitUntil: 'domcontentloaded' }); + const plate = page.locator(`#msg_${messageId}`); + await plate.waitFor({ state: 'visible' }); + const beforeText = (await plate.locator('.msg-content').textContent())?.trim() ?? ''; + await page.screenshot({ path: path.join(artifactDir, 'invader-message-before-response.png'), fullPage: true }); + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/api/trpc/messages.respond') && response.request().method() === 'POST' + ); + page.once('dialog', (dialog) => dialog.accept()); + await plate.locator('.prompt-yes').click(); + const response = await responsePromise; + await page.waitForTimeout(2_000); + await page.screenshot({ path: path.join(artifactDir, 'invader-message-after-response.png'), fullPage: true }); + log('invader-message-browser-response', { + messageId, + userIndex, + generalName: user.generalName, + beforeText, + responseStatus: response.status(), + browserErrors, + applicationHttpErrors, + }); + if (response.status() !== 200 || browserErrors || applicationHttpErrors) process.exitCode = 1; + } finally { + await context.close(); + await browser.close(); + } +}; + +const requestAction = async (): Promise => { + const action = process.argv[3]; + if (!['PAUSE', 'RESUME', 'STOP', 'DELAY', 'ACCELERATE'].includes(action ?? '')) { + throw new Error('action must be PAUSE, RESUME, STOP, DELAY, or ACCELERATE'); + } + const durationMinutes = process.argv[4] ? Number(process.argv[4]) : undefined; + if ( + (action === 'DELAY' || action === 'ACCELERATE') && + (!Number.isInteger(durationMinutes) || durationMinutes! < 1) + ) { + throw new Error('DELAY and ACCELERATE require a positive integer minute duration.'); + } + const gateway = await loginAdmin(); + const result = await gateway.admin.profiles.requestAction.mutate({ + profileName, + action: action as 'PAUSE' | 'RESUME' | 'STOP' | 'DELAY' | 'ACCELERATE', + ...(durationMinutes ? { durationMinutes } : {}), + reason: `isolated lifecycle integration ${action.toLowerCase()}`, + }); + log('admin-action-requested', { + action, + durationMinutes: durationMinutes ?? null, + accepted: result.ok, + runtimeActionId: result.action && 'id' in result.action ? result.action.id : null, + }); +}; + +const waitProfileStatus = async (): Promise => { + const expectedStatus = process.argv[3]?.trim(); + if (!expectedStatus) throw new Error('wait-profile-status requires an expected profile status.'); + const timeoutMs = Number(process.env.SAMMO_LIVE_STATUS_TIMEOUT_MS ?? '300000'); + if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000) { + throw new Error('SAMMO_LIVE_STATUS_TIMEOUT_MS must be an integer of at least 1000.'); + } + const gateway = await loginAdmin(); + const startedAt = Date.now(); + let lastStatus: string | null = null; + while (Date.now() - startedAt < timeoutMs) { + const profiles = (await gateway.admin.profiles.list.query()) as unknown as Array< + { profileName: string; status: string } & Record + >; + const profile = profiles.find((entry) => entry.profileName === profileName); + if (!profile) throw new Error(`Profile not found: ${profileName}`); + if (profile.status !== lastStatus) { + lastStatus = profile.status; + log('profile-status-wait', { expectedStatus, observedStatus: profile.status }); + } + if (profile.status === expectedStatus) { + log('profile-status-reached', { + expectedStatus, + elapsedSeconds: Math.round((Date.now() - startedAt) / 1000), + }); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + throw new Error(`Profile did not reach ${expectedStatus}; last observed status was ${lastStatus ?? 'unknown'}.`); +}; + +const waitRuntimeAction = async (): Promise => { + const actionId = process.argv[3]?.trim(); + if (!actionId) throw new Error('wait-runtime-action requires an action ID.'); + const gateway = await loginAdmin(); + const deadline = Date.now() + Number(process.env.SAMMO_LIVE_STATUS_TIMEOUT_MS ?? '300000'); + let lastStatus: string | null = null; + while (Date.now() < deadline) { + const profiles = (await gateway.admin.profiles.list.query()) as unknown as Array<{ + profileName: string; + runtimeActions?: Array<{ id: string; status: string; detail?: string | null; attempts?: number }>; + }>; + const action = profiles + .find((entry) => entry.profileName === profileName) + ?.runtimeActions?.find((entry) => entry.id === actionId); + if (!action) throw new Error(`Runtime action not found: ${actionId}`); + if (action.status !== lastStatus) { + lastStatus = action.status; + log('runtime-action-wait', { actionId, status: action.status, detail: action.detail ?? null }); + } + if (['APPLIED', 'FAILED', 'IGNORED'].includes(action.status)) { + log('runtime-action-terminal', { actionId, ...action }); + if (action.status !== 'APPLIED') process.exitCode = 1; + return; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + throw new Error(`Runtime action did not finish: ${actionId}; last status ${lastStatus ?? 'unknown'}.`); +}; + +const monitorUsers = async (): Promise => { + const opened = await openUserPages(); + const durationMinutes = Number(process.env.SAMMO_LIVE_MONITOR_MINUTES ?? '30'); + if (!Number.isFinite(durationMinutes) || durationMinutes < 10 || durationMinutes > 120) { + throw new Error('SAMMO_LIVE_MONITOR_MINUTES must be from 10 to 120.'); + } + const outputPath = path.join(artifactDir, 'user-monitor.ndjson'); + const startedAt = Date.now(); + const deadline = startedAt + durationMinutes * 60_000; + let sampleIndex = 0; + let messageIndex = Number(process.env.SAMMO_LIVE_MONITOR_START_INDEX ?? '0'); + if (!Number.isInteger(messageIndex) || messageIndex < 0) { + throw new Error('SAMMO_LIVE_MONITOR_START_INDEX must be a non-negative integer.'); + } + let fanoutFailures = 0; + try { + await appendFile( + outputPath, + `${JSON.stringify({ at: new Date().toISOString(), event: 'monitor-started', viewers: 10, durationMinutes })}\n`, + { mode: 0o600 } + ); + while (Date.now() < deadline) { + sampleIndex += 1; + const info = await createGame(opened.state.users![0]!.gameToken).lobby.info.query(); + const visiblePanels = ( + await Promise.all(opened.records.map(({ page }) => page.locator('.MessagePanel').isVisible())) + ).filter(Boolean).length; + const sample = { + at: new Date().toISOString(), + event: 'user-monitor-sample', + sampleIndex, + visiblePanels, + year: info.year, + month: info.month, + turnTerm: info.turnTerm, + userCnt: info.userCnt, + npcCnt: info.npcCnt, + nationCnt: info.nationCnt, + wallElapsedSeconds: Math.round((Date.now() - startedAt) / 1000), + }; + log('user-monitor-sample', sample); + await appendFile(outputPath, `${JSON.stringify(sample)}\n`); + + if (sampleIndex % 2 === 0) { + messageIndex += 1; + const sender = opened.records[(messageIndex - 1) % opened.records.length]!; + const text = `MONITOR-${opened.state.runId.replaceAll('-', '').slice(0, 8)}-${String(messageIndex).padStart(2, '0')}`; + await sender.page.locator('.PublicTalk').getByRole('button', { name: '↩ 여기로', exact: true }).click(); + await sender.page.locator('.message-text').fill(text); + const wallSentAt = new Date().toISOString(); + await sender.page.getByRole('button', { name: '서신전달&갱신', exact: true }).click(); + const fanout = await Promise.all( + opened.records.map(({ page }) => + page + .locator('.PublicTalk') + .getByText(text, { exact: true }) + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false) + ) + ); + const viewersObserved = fanout.filter(Boolean).length; + if (viewersObserved !== 10) fanoutFailures += 1; + const messageEvent = { + at: new Date().toISOString(), + event: 'monitor-message-fanout', + sender: sender.index, + text, + wallSentAt, + viewersObserved, + }; + log('monitor-message-fanout', messageEvent); + await appendFile(outputPath, `${JSON.stringify(messageEvent)}\n`); + } + if (sampleIndex % 10 === 0) { + await opened.records[0]!.page.screenshot({ + path: path.join(artifactDir, `monitor-${String(sampleIndex).padStart(3, '0')}.png`), + fullPage: true, + }); + } + const remaining = deadline - Date.now(); + if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(30_000, remaining))); + } + const errors = opened.errors(); + log('user-monitor-complete', { + durationMinutes, + samples: sampleIndex, + messages: messageIndex, + fanoutFailures, + ...errors, + }); + await appendFile( + outputPath, + `${JSON.stringify({ at: new Date().toISOString(), event: 'monitor-complete', samples: sampleIndex, messages: messageIndex, fanoutFailures, ...errors })}\n` + ); + if ( + errors.browserErrors || + errors.applicationHttpErrors || + fanoutFailures || + sampleIndex < durationMinutes * 2 - 2 + ) { + process.exitCode = 1; + } + } finally { + await Promise.all(opened.records.map(({ context }) => context.close())); + await opened.browser.close(); + } +}; + +const command = process.argv[2]; +if (command === 'reset') await reset(); +else if (command === 'wait-reset') await waitReset(); +else if (command === 'deploy') await deploy(); +else if (command === 'wait-deploy') await waitDeploy(); +else if (command === 'status') await status(); +else if (command === 'prepare-users') await prepareUsers(); +else if (command === 'preopen-messages') await preopenMessages(); +else if (command === 'verified-preopen-messages') await preopenMessages(true); +else if (command === 'repair-preopen-fixture') await repairPreopenFixture(); +else if (command === 'database-status') await databaseStatus(); +else if (command === 'prepare-paused-betting') await preparePausedBetting(); +else if (command === 'submit-paused-betting') await submitPausedBetting(); +else if (command === 'reserve-user-enlistments') await reserveUserEnlistments(); +else if (command === 'prepare-action-fixture') await prepareActionFixture(); +else if (command === 'repair-action-item-fixture') await repairActionItemFixture(); +else if (command === 'exercise-paused-actions') await exercisePausedActions(); +else if (command === 'verify-paused-wall-expiry') await verifyPausedWallExpiry(); +else if (command === 'prepare-tournament-bet') await prepareTournamentBet(); +else if (command === 'submit-paused-tournament-bet') await submitPausedTournamentBet(); +else if (command === 'reserve-actionable-commands') await reserveActionableCommands(); +else if (command === 'wait-actionable-messages') await waitActionableMessages(); +else if (command === 'respond-actionable-messages') await respondActionableMessages(); +else if (command === 'reserve-no-aggression-cancellation') await reserveNoAggressionCancellation(); +else if (command === 'wait-no-aggression-cancellation') await waitNoAggressionCancellation(); +else if (command === 'respond-no-aggression-cancellation') await respondNoAggressionCancellation(); +else if (command === 'npc-action-audit') await npcActionAudit(); +else if (command === 'repair-opening-clock-runtime') await repairOpeningClockRuntime(); +else if (command === 'verify-monitor-message') await verifyExistingMonitorMessage(); +else if (command === 'resume-daemon') await resumeDaemon(); +else if (command === 'fast-forward') await fastForward(); +else if (command === 'place-invader-recipients') await placeInvaderRecipients(); +else if (command === 'respond-invader-browser') await respondToInvaderMessageInBrowser(); +else if (command === 'action') await requestAction(); +else if (command === 'wait-profile-status') await waitProfileStatus(); +else if (command === 'wait-runtime-action') await waitRuntimeAction(); +else if (command === 'monitor-users') await monitorUsers(); +else + throw new Error( + 'usage: live-ten-user-lifecycle.ts ' + ); 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)), diff --git a/tools/integration-tests/test/troopStaticEvent.integration.test.ts b/tools/integration-tests/test/troopStaticEvent.integration.test.ts index c9bbcc04..3853a46f 100644 --- a/tools/integration-tests/test/troopStaticEvent.integration.test.ts +++ b/tools/integration-tests/test/troopStaticEvent.integration.test.ts @@ -13,7 +13,10 @@ import { } from '../src/turn-differential/referenceSnapshot.js'; const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd()); -const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); +const integration = + workspaceRoot && process.env.TURN_DIFFERENTIAL_REFERENCE === '1' + ? describe + : (_name: string, _factory: () => void): void => undefined; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const persistence = describe.skipIf(!databaseUrl); const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; diff --git a/tools/integration-tests/tsconfig.json b/tools/integration-tests/tsconfig.json index c9b8d733..631e1714 100644 --- a/tools/integration-tests/tsconfig.json +++ b/tools/integration-tests/tsconfig.json @@ -4,5 +4,5 @@ "types": ["node"], "noEmit": true }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"] } diff --git a/tools/legacy-db-migration/src/currentSeason.ts b/tools/legacy-db-migration/src/currentSeason.ts index 043ed5e7..255169c6 100644 --- a/tools/legacy-db-migration/src/currentSeason.ts +++ b/tools/legacy-db-migration/src/currentSeason.ts @@ -391,7 +391,10 @@ const replaceCurrentSeason = async ( await client.query('BEGIN'); try { const activeLease = await client.query( - `SELECT 1 FROM turn_daemon_lease WHERE lease_until > CURRENT_TIMESTAMP LIMIT 1` + `SELECT 1 + FROM turn_daemon_lease + WHERE lease_until > CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + LIMIT 1` ); if (activeLease.rowCount) { throw new Error('Refusing to replace a current season while a turn daemon lease is active'); diff --git a/tools/load-tests/package.json b/tools/load-tests/package.json index ab8b32a4..7d50e7d3 100644 --- a/tools/load-tests/package.json +++ b/tools/load-tests/package.json @@ -4,19 +4,22 @@ "version": "0.0.0", "type": "module", "scripts": { - "run": "pnpm -w exec tsx tools/load-tests/src/cli.ts run", - "dry-run": "pnpm -w exec tsx tools/load-tests/src/cli.ts dry-run", - "validate": "pnpm -w exec tsx tools/load-tests/src/cli.ts validate", - "prepare:capacity": "pnpm -w exec tsx tools/load-tests/src/cli.ts prepare", - "seed": "pnpm -w exec tsx tools/load-tests/src/cli.ts seed", - "verify-fixture": "pnpm -w exec tsx tools/load-tests/src/cli.ts verify-fixture", - "activate-coverage": "pnpm -w exec tsx tools/load-tests/src/cli.ts activate-coverage", - "measure-page-navigation": "pnpm -w exec tsx tools/load-tests/src/cli.ts measure-page-navigation", - "measure-turn-cycle": "pnpm -w exec tsx tools/load-tests/src/cli.ts measure-turn-cycle", - "measure-turn-flush": "pnpm -w exec tsx tools/load-tests/src/cli.ts measure-turn-flush", - "materialize-calibration": "pnpm -w exec tsx tools/load-tests/src/cli.ts materialize-calibration", - "cleanup": "pnpm -w exec tsx tools/load-tests/src/cli.ts cleanup", - "test": "pnpm -w exec tsx --test tools/load-tests/test/*.test.ts", + "run": "cd ../.. && tsx tools/load-tests/src/cli.ts run", + "dry-run": "cd ../.. && tsx tools/load-tests/src/cli.ts dry-run", + "validate": "cd ../.. && tsx tools/load-tests/src/cli.ts validate", + "prepare:capacity": "cd ../.. && tsx tools/load-tests/src/cli.ts prepare", + "seed": "cd ../.. && tsx tools/load-tests/src/cli.ts seed", + "verify-fixture": "cd ../.. && tsx tools/load-tests/src/cli.ts verify-fixture", + "activate-coverage": "cd ../.. && tsx tools/load-tests/src/cli.ts activate-coverage", + "measure-page-navigation": "cd ../.. && tsx tools/load-tests/src/cli.ts measure-page-navigation", + "measure-turn-cycle": "cd ../.. && tsx tools/load-tests/src/cli.ts measure-turn-cycle", + "measure-turn-flush": "cd ../.. && tsx tools/load-tests/src/cli.ts measure-turn-flush", + "materialize-calibration": "cd ../.. && tsx tools/load-tests/src/cli.ts materialize-calibration", + "cleanup": "cd ../.. && tsx tools/load-tests/src/cli.ts cleanup", + "test": "cd ../.. && tsx --test tools/load-tests/test/*.test.ts", "typecheck": "pnpm -w tsc7 -p tools/load-tests/tsconfig.json --noEmit" + }, + "devDependencies": { + "tsx": "^4.23.12" } }