diff --git a/.env.example b/.env.example index 6fe4ecf..4dd3aff 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,9 @@ GAME_API_PORT=14000 PROFILE=hwe SCENARIO=default DAEMON_REQUEST_TIMEOUT_MS=5000 +# realtime은 벽시계 경과를 game tick으로 투영합니다. manual은 daemon이 +# 다음 월 snapshot을 대기 없이 실행하며 테스트/장기 시뮬레이션에 사용합니다. +GAME_CLOCK_MODE=realtime TRPC_PATH=/trpc # Frontend public URLs diff --git a/app/game-api/src/auction/open.ts b/app/game-api/src/auction/open.ts index 8032202..bed30ed 100644 --- a/app/game-api/src/auction/open.ts +++ b/app/game-api/src/auction/open.ts @@ -1,7 +1,9 @@ import { TRPCError } from '@trpc/server'; import type { GameApiContext } from '../context.js'; +import { loadCurrentGameTime } from '../services/gameClock.js'; import { buildAuctionTimerKeys } from './keys.js'; +import { resolveAuctionTimerScore } from './scheduler.js'; export type OpenAuctionInput = | { @@ -36,7 +38,10 @@ export const openAuctionWithDaemon = async ( const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const closeAt = new Date(result.closeAt); - await ctx.redis.zAdd(timerKeys.timerKey, [{ score: closeAt.getTime(), value: String(result.auctionId) }]); + const gameTime = await loadCurrentGameTime(ctx.db); + await ctx.redis.zAdd(timerKeys.timerKey, [ + { score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) }, + ]); return { auctionId: result.auctionId, closeAt: result.closeAt, diff --git a/app/game-api/src/auction/scheduler.ts b/app/game-api/src/auction/scheduler.ts index 65d8fbd..4dea933 100644 --- a/app/game-api/src/auction/scheduler.ts +++ b/app/game-api/src/auction/scheduler.ts @@ -3,6 +3,7 @@ import { GamePrisma } from '@sammo-ts/infra'; import type { DatabaseClient } from '../context.js'; import type { AuctionTimerRow } from './types.js'; import type { AuctionTimerKeys } from './keys.js'; +import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js'; interface RedisSortedSetClient { zAdd(key: string, values: Array<{ score: number; value: string }>): Promise; @@ -16,6 +17,15 @@ export interface AuctionEventUpdate { eventAt: Date; } +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(); +}; + export const seedAuctionTimers = async ( db: DatabaseClient, redis: RedisSortedSetClient, @@ -23,7 +33,7 @@ export const seedAuctionTimers = async ( ): Promise => { const rows = await db.$queryRaw( GamePrisma.sql` - SELECT id, close_at as "closeAt", status + SELECT id, close_at as "closeAt", close_tick as "closeTick", status FROM auction WHERE status IN ('OPEN', 'FINALIZING') ` @@ -32,7 +42,11 @@ export const seedAuctionTimers = async ( return 0; } - const payload = rows.map((row) => ({ score: row.closeAt.getTime(), value: String(row.id) })); + const gameTime = await loadCurrentGameTime(db); + const payload = rows.map((row) => ({ + score: resolveAuctionTimerScore(gameTime, row.closeAt, row.closeTick), + value: String(row.id), + })); await redis.zAdd(keys.timerKey, payload); return payload.length; }; @@ -44,10 +58,13 @@ export const applyAuctionEvent = async ( event: AuctionEventUpdate ): Promise => { const now = new Date(); + const gameTime = await loadCurrentGameTime(db, now); + const closeTick = gameTime.dateToTick(event.closeAt); const updated = await db.$executeRaw( GamePrisma.sql` UPDATE auction SET close_at = ${event.closeAt}, + close_tick = ${closeTick === null ? null : BigInt(closeTick)}, latest_event_id = ${event.eventId}, latest_event_at = ${event.eventAt}, updated_at = ${now} @@ -61,7 +78,12 @@ export const applyAuctionEvent = async ( ); if (updated > 0) { - await redis.zAdd(keys.timerKey, [{ score: event.closeAt.getTime(), value: String(event.auctionId) }]); + await redis.zAdd(keys.timerKey, [ + { + score: resolveAuctionTimerScore(gameTime, event.closeAt, closeTick === null ? null : BigInt(closeTick)), + value: String(event.auctionId), + }, + ]); return true; } diff --git a/app/game-api/src/auction/types.ts b/app/game-api/src/auction/types.ts index 117d7fd..8f49332 100644 --- a/app/game-api/src/auction/types.ts +++ b/app/game-api/src/auction/types.ts @@ -3,6 +3,7 @@ export type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED'; export interface AuctionTimerRow { id: number; closeAt: Date; + closeTick: bigint | null; status: AuctionStatus; } diff --git a/app/game-api/src/auction/worker.ts b/app/game-api/src/auction/worker.ts index 2b44763..11c4206 100644 --- a/app/game-api/src/auction/worker.ts +++ b/app/game-api/src/auction/worker.ts @@ -9,9 +9,10 @@ import { import { resolveGameApiConfigFromEnv } from '../config.js'; import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js'; +import { loadCurrentGameTime } from '../services/gameClock.js'; import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js'; import { buildAuctionTimerKeys } from './keys.js'; -import { seedAuctionTimers } from './scheduler.js'; +import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js'; interface RedisTimerClient { zRangeByScore( @@ -86,8 +87,9 @@ export const processDueAuctionId = async (options: { historyKey: string; id: string; nowMs: number; + nowTick?: number | null; }): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => { - const { db, redis, timerKey, historyKey, id, nowMs } = options; + const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null } = options; const auctionId = Number(id); if (!Number.isSafeInteger(auctionId) || auctionId < 1) { return 'IGNORED'; @@ -102,13 +104,16 @@ export const processDueAuctionId = async (options: { updated_at = ${now} WHERE id = ${auctionId} AND status = 'OPEN' - AND close_at <= ${now} + AND ( + (close_tick IS NOT NULL AND close_tick <= ${nowTick === null ? null : BigInt(nowTick)}) + OR (close_tick IS NULL AND close_at <= ${now}) + ) ` ); const current = await transaction.auction.findUnique({ where: { id: auctionId }, - select: { status: true, closeAt: true }, + select: { status: true, closeAt: true, closeTick: true }, }); if (!current) { if (updated > 0) { @@ -117,7 +122,7 @@ export const processDueAuctionId = async (options: { return { status: 'IGNORED' as const }; } if (current.status === 'OPEN') { - return { status: 'RESCHEDULED' as const, closeAt: current.closeAt }; + return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick }; } if (current.status !== 'FINALIZING') { return { status: 'IGNORED' as const }; @@ -159,7 +164,13 @@ export const processDueAuctionId = async (options: { return 'FINALIZING'; } if (outcome.status === 'RESCHEDULED') { - await redis.zAdd(timerKey, [{ score: outcome.closeAt.getTime(), value: String(auctionId) }]); + const gameTime = await loadCurrentGameTime(db, now); + await redis.zAdd(timerKey, [ + { + score: resolveAuctionTimerScore(gameTime, outcome.closeAt, outcome.closeTick), + value: String(auctionId), + }, + ]); return 'RESCHEDULED'; } return 'IGNORED'; @@ -188,17 +199,20 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom try { while (!control.signal.aborted) { - const nowMs = Date.now(); - const historyTrimBefore = nowMs - config.auctionTimerRetentionSeconds * 1000; + const operationalNowMs = Date.now(); + const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs)); + const gameNowMs = gameTime.now.getTime(); + const dueScore = gameTime.tick ?? gameNowMs; + const historyTrimBefore = operationalNowMs - config.auctionTimerRetentionSeconds * 1000; if (historyTrimBefore > 0) { await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore); } - if (nowMs >= nextResyncAt) { + if (operationalNowMs >= nextResyncAt) { await seedAuctionTimers(postgres.prisma, redis.client, keys); - nextResyncAt = nowMs + config.auctionTimerResyncMs; + nextResyncAt = operationalNowMs + config.auctionTimerResyncMs; } - const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, nowMs, 100); + const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100); if (dueIds.length > 0) { for (const id of dueIds) { try { @@ -208,7 +222,8 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom timerKey: keys.timerKey, historyKey: keys.historyKey, id, - nowMs, + nowMs: gameNowMs, + nowTick: gameTime.tick, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown auction worker error'; @@ -233,9 +248,9 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom const nextDueMs = await getNextDueMs(redis.client, keys.timerKey); const waitMs = - nextDueMs === null - ? config.auctionTimerPollMs - : Math.max(0, Math.min(config.auctionTimerPollMs, nextDueMs - Date.now())); + gameTime.tick === null && nextDueMs !== null + ? Math.max(0, Math.min(config.auctionTimerPollMs, nextDueMs - gameNowMs)) + : config.auctionTimerPollMs; await waitForWorkerPoll(control.signal, waitMs); } } finally { diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 62d160e..7b32945 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -56,8 +56,17 @@ export const zWorldStateMeta = z.object({ }); export type WorldStateMeta = z.infer; -export type WorldStateRow = GamePrisma.WorldStateGetPayload>; -export type GeneralRow = GamePrisma.GeneralGetPayload>; +type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload>; +type PrismaGeneralRow = GamePrisma.GeneralGetPayload>; +type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'; +type GeneralClockFields = 'turnTick' | 'recentWarTick'; + +// Transitional API fixtures may still model the pre-clock row. Runtime Prisma +// rows always include these nullable columns after migration. +export type WorldStateRow = Omit & + Partial>; +export type GeneralRow = Omit & + Partial>; export type GeneralTurnRow = GamePrisma.GeneralTurnGetPayload>; export type NationTurnRow = GamePrisma.NationTurnGetPayload>; export type CityRow = GamePrisma.CityGetPayload>; diff --git a/app/game-api/src/messages/diplomaticResponse.ts b/app/game-api/src/messages/diplomaticResponse.ts index 9e2f11d..84a47d2 100644 --- a/app/game-api/src/messages/diplomaticResponse.ts +++ b/app/game-api/src/messages/diplomaticResponse.ts @@ -20,6 +20,7 @@ import { import type { DatabaseClient, GeneralRow, InputJsonValue, NationRow } from '../context.js'; import { loadMapDefinitionByName } from '../maps/mapDefinition.js'; import { resolveNationPermission } from '../router/nation/shared.js'; +import { loadCurrentGameTime } from '../services/gameClock.js'; import { fetchMessageByIdForUpdate, insertMessage, invalidateMessages } from './store.js'; const ACTION_NAMES: Record = { @@ -186,7 +187,7 @@ export const respondToDiplomaticMessage = async (options: { if (!world) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '게임 상태가 없습니다.' }); } - const now = new Date(); + const now = (await loadCurrentGameTime(db)).now; const action = parseAction(message.payload.option?.action); if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) { throw new TRPCError({ code: 'BAD_REQUEST', message: '응답할 수 없는 메시지입니다.' }); diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index 98aec9b..a2cd83f 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -1,6 +1,7 @@ import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic'; import type { DatabaseClient } from '../context.js'; +import { loadCurrentGameTime } from '../services/gameClock.js'; export interface MessageView { id: number; @@ -59,15 +60,26 @@ const toMessageView = (row: MessageRow): MessageView => { }; export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise => { + const gameTime = await loadCurrentGameTime(db); + const toTickOrNull = (date: Date): bigint | null => { + try { + const tick = gameTime.dateToTick(date); + return tick === null ? null : BigInt(tick); + } catch { + return null; + } + }; const rows = await db.$queryRaw>` - INSERT INTO message (mailbox, type, src, dest, time, valid_until, message) + 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 @@ -87,12 +99,16 @@ 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 valid_until > NOW() + AND ( + (valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)}) + OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) + ) AND id >= ${fromSeq} ORDER BY id DESC LIMIT ${params.limit} @@ -108,12 +124,16 @@ 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 valid_until > NOW() + AND ( + (valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)}) + OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) + ) AND id < ${params.toSeq} ORDER BY id DESC LIMIT ${params.limit} @@ -123,10 +143,15 @@ export const fetchOldMessagesFromMailbox = async (params: { }; 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 valid_until > NOW() + WHERE id = ${id} + AND ( + (valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)}) + OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) + ) LIMIT 1 `; const row = rows[0]; @@ -141,10 +166,15 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise< }; export const fetchMessageByIdForUpdate = 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 valid_until > NOW() + WHERE id = ${id} + AND ( + (valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)}) + OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) + ) LIMIT 1 FOR UPDATE `; @@ -162,8 +192,12 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Promise => { const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); if (uniqueIds.length === 0) return; + const gameTime = await loadCurrentGameTime(db); await db.message.updateMany({ where: { id: { in: uniqueIds } }, - data: { validUntil: new Date() }, + data: { + validUntil: gameTime.now, + ...(gameTime.tick === null ? {} : { validUntilTick: BigInt(gameTime.tick) }), + }, }); }; diff --git a/app/game-api/src/router/auction/index.ts b/app/game-api/src/router/auction/index.ts index bc1b476..1d9313e 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -9,7 +9,8 @@ import { ItemLoader, isItemKey } from '@sammo-ts/logic'; import { asNumber, asRecord } from '@sammo-ts/common'; import { buildAuctionAlias } from '@sammo-ts/logic'; import { openAuctionWithDaemon } from '../../auction/open.js'; - +import { resolveAuctionTimerScore } from '../../auction/scheduler.js'; +import { loadCurrentGameTime } from '../../services/gameClock.js'; const zBidInput = z.object({ auctionId: z.number().int().positive(), @@ -99,10 +100,7 @@ const ensureAuctionSeasonActive = async (db: DatabaseClient): Promise => { } }; -const loadAuction = async ( - db: DatabaseClient, - auctionId: number -): Promise => { +const loadAuction = async (db: DatabaseClient, auctionId: number): Promise => { const rows = (await db.$queryRaw( GamePrisma.sql` SELECT id, @@ -190,10 +188,7 @@ export const auctionRouter = router({ const [auctions, worldState, point, recentLogs] = await Promise.all([ ctx.db.auction.findMany({ where: { - OR: [ - { type: { in: ['BUY_RICE', 'SELL_RICE'] }, status: 'OPEN' }, - { type: 'UNIQUE_ITEM' }, - ], + OR: [{ type: { in: ['BUY_RICE', 'SELL_RICE'] }, status: 'OPEN' }, { type: 'UNIQUE_ITEM' }], }, orderBy: [{ status: 'asc' }, { id: 'desc' }], take: 120, @@ -238,7 +233,7 @@ export const auctionRouter = router({ const hiddenSeed = typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number' ? worldMeta.hiddenSeed - : worldState?.id ?? 0; + : (worldState?.id ?? 0); const callerAlias = buildAuctionAlias(general.id, hiddenSeed, configConst); const mapped = auctions.map((auction) => { @@ -252,8 +247,8 @@ export const auctionRouter = router({ status: auction.status, hostGeneralId: isUnique ? null : auction.hostGeneralId, hostName: isUnique - ? auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst) - : auction.hostName ?? names.get(auction.hostGeneralId) ?? '상인', + ? (auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst)) + : (auction.hostName ?? names.get(auction.hostGeneralId) ?? '상인'), isCallerHost: auction.hostGeneralId === general.id, closeAt: auction.closeAt.toISOString(), detail, @@ -262,7 +257,7 @@ export const auctionRouter = router({ amount: highestBid.amount, bidderName: isUnique ? buildAuctionAlias(highestBid.generalId, hiddenSeed, configConst) - : names.get(highestBid.generalId) ?? '상인', + : (names.get(highestBid.generalId) ?? '상인'), isCaller: highestBid.generalId === general.id, eventAt: highestBid.eventAt.toISOString(), } @@ -305,14 +300,13 @@ export const auctionRouter = router({ const hiddenSeed = typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number' ? worldMeta.hiddenSeed - : worldState?.id ?? 0; + : (worldState?.id ?? 0); return { auction: { id: auction.id, targetCode: auction.targetCode, status: auction.status, - hostName: - auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst), + hostName: auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst), isCallerHost: auction.hostGeneralId === general.id, closeAt: auction.closeAt.toISOString(), detail: parseDetail(auction.detail), @@ -362,7 +356,8 @@ export const auctionRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' }); } - const now = new Date(); + const gameTime = await loadCurrentGameTime(ctx.db); + const { now } = gameTime; if (auction.closeAt <= now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' }); } @@ -418,7 +413,9 @@ export const auctionRouter = router({ const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const nextCloseAt = new Date(result.closeAt); - await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]); + await ctx.redis.zAdd(timerKeys.timerKey, [ + { score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, + ]); return { ok: true }; }), @@ -434,7 +431,8 @@ export const auctionRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' }); } - const now = new Date(); + const gameTime = await loadCurrentGameTime(ctx.db); + const { now } = gameTime; if (auction.closeAt <= now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' }); } @@ -490,7 +488,9 @@ export const auctionRouter = router({ const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const nextCloseAt = new Date(result.closeAt); - await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]); + await ctx.redis.zAdd(timerKeys.timerKey, [ + { score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, + ]); return { ok: true }; }), @@ -506,7 +506,8 @@ export const auctionRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' }); } - const now = new Date(); + const gameTime = await loadCurrentGameTime(ctx.db); + const { now } = gameTime; if (auction.closeAt <= now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' }); } @@ -586,7 +587,10 @@ export const auctionRouter = router({ } const otherItem = await itemLoader.load(other.targetCode); if (otherItem.slot === itemModule.slot) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '1순위 입찰자인 경매중에 같은 부위가 있습니다.' }); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '1순위 입찰자인 경매중에 같은 부위가 있습니다.', + }); } } } @@ -620,7 +624,9 @@ export const auctionRouter = router({ const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const nextCloseAt = new Date(result.closeAt); - await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]); + await ctx.redis.zAdd(timerKeys.timerKey, [ + { score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, + ]); return { ok: true }; }), diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 86e4287..4bd3fd8 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -15,6 +15,7 @@ import { } from '@sammo-ts/logic'; import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js'; import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js'; +import { loadCurrentGameTime } from '../../services/gameClock.js'; import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js'; import { ConflictingTurnDaemonCommandError, @@ -373,10 +374,12 @@ export const joinRouter = router({ message: 'World state is not initialized.', }); } + const gameTime = await loadCurrentGameTime(ctx.db); return reserveSelectionPool({ db: ctx.db, worldState, userId, + now: gameTime.now, seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId, }); }), @@ -558,6 +561,7 @@ export const joinRouter = router({ }); } try { + const gameTime = await loadCurrentGameTime(ctx.db); return await reserveNpcPossessionCandidates({ db: ctx.db, worldState, @@ -565,6 +569,7 @@ export const joinRouter = router({ ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id, refresh: input.refresh, keepIds: input.keepIds, + now: gameTime.now, }); } 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 c20d789..fe27e81 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -26,6 +26,7 @@ import { publishRealtimeEvent } from '../../realtime/publisher.js'; import { getOwnedGeneral } from '../shared/general.js'; import { resolveNationPermission } from '../nation/shared.js'; import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js'; +import { loadCurrentGameTime } from '../../services/gameClock.js'; const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); @@ -288,7 +289,8 @@ export const messagesRouter = router({ if (message.payload.option?.deletable === false) { throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' }); } - if (Date.now() - message.time.getTime() > 5 * 60 * 1000) { + 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; @@ -389,155 +391,152 @@ export const messagesRouter = router({ }; }), send: accessAuthedInputProcedure( - z.object({ - generalId: z.number().int().positive(), - mailbox: z.number().int(), - text: z.string().min(1), - }) - ) - .mutation(async ({ ctx, input }) => { - const general = await getOwnedGeneral(ctx, input.generalId); - if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) { + z.object({ + generalId: z.number().int().positive(), + mailbox: z.number().int(), + text: z.string().min(1), + }) + ).mutation(async ({ ctx, input }) => { + const general = await getOwnedGeneral(ctx, input.generalId); + if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '메시지 전송이 제한된 계정입니다.', + }); + } + + const src = await buildTargetFromGeneral(ctx.db, general); + const { now } = await loadCurrentGameTime(ctx.db); + const validUntil = new Date('9999-12-31T00:00:00Z'); + + let msgType: MessageType; + let dest = src; + let receiverMailbox = input.mailbox; + + if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) { + if (hasPenalty(general.penalty, 'noSendPublicMsg')) { throw new TRPCError({ code: 'FORBIDDEN', - message: '메시지 전송이 제한된 계정입니다.', + message: '공개 메세지를 보낼 수 없습니다.', }); } - - const src = await buildTargetFromGeneral(ctx.db, general); - const now = new Date(); - const validUntil = new Date('9999-12-31T00:00:00Z'); - - let msgType: MessageType; - let dest = src; - let receiverMailbox = input.mailbox; - - if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) { - if (hasPenalty(general.penalty, 'noSendPublicMsg')) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: '공개 메세지를 보낼 수 없습니다.', - }); - } - msgType = 'public'; - } else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) { - const sourceNation = - general.nationId > 0 - ? await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { meta: true }, - }) - : null; - const permission = - general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1; - const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE; - const nationInfo = await resolveNationInfo(ctx.db, destNationId); - if (destNationId > 0) { - const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } }); - if (!destNation) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: '존재하지 않는 국가입니다.', - }); - } - } - dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color); - msgType = destNationId === general.nationId ? 'national' : 'diplomacy'; - receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId; - } else if (input.mailbox > 0) { - if (hasPenalty(general.penalty, 'noSendPrivateMsg')) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: '개인 메세지를 보낼 수 없습니다.', - }); - } - const intervalSeconds = Math.max( - 0, - Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2)) - ); - if (intervalSeconds > 0) { - const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`; - const acquired = await ctx.redis.set(rateLimitKey, '1', { - NX: true, - PX: intervalSeconds * 1000, - }); - if (acquired === null) { - throw new TRPCError({ - code: 'TOO_MANY_REQUESTS', - message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`, - }); - } - } - const destGeneral = await ctx.db.general.findUnique({ - where: { id: input.mailbox }, - }); - if (!destGeneral) { + msgType = 'public'; + } else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) { + const sourceNation = + general.nationId > 0 + ? await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { meta: true }, + }) + : null; + const permission = + general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1; + const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE; + const nationInfo = await resolveNationInfo(ctx.db, destNationId); + if (destNationId > 0) { + const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } }); + if (!destNation) { throw new TRPCError({ code: 'NOT_FOUND', - message: '존재하지 않는 유저입니다.', + message: '존재하지 않는 국가입니다.', }); } - const [sourceNation, destNation] = await Promise.all([ - general.nationId > 0 - ? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } }) - : null, - destGeneral.nationId > 0 - ? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } }) - : null, - ]); - const sourcePermission = - sourceNation && general.nationId > 0 - ? resolveNationPermission(general, sourceNation.meta, false) - : -1; - const destPermission = - destNation && destGeneral.nationId > 0 - ? resolveNationPermission(destGeneral, destNation.meta, false) - : -1; - if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: '외교권자끼리는 메시지를 보낼 수 없습니다.', - }); - } - dest = await buildTargetFromGeneral(ctx.db, destGeneral); - msgType = 'private'; - } else { + } + dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color); + msgType = destNationId === general.nationId ? 'national' : 'diplomacy'; + receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId; + } else if (input.mailbox > 0) { + if (hasPenalty(general.penalty, 'noSendPrivateMsg')) { throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Invalid mailbox.', + code: 'FORBIDDEN', + message: '개인 메세지를 보낼 수 없습니다.', }); } - - const draft: MessageDraft = { - msgType, - src, - dest, - text: input.text, - time: now, - validUntil, - option: {}, - }; - - const result = await sendMessage( - { - insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft), - }, - draft + const intervalSeconds = Math.max( + 0, + Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2)) ); - - try { - await publishRealtimeEvent(ctx.redis, ctx.profile.name, { - type: 'messageCreated', - at: now.toISOString(), - mailbox: receiverMailbox, - msgType, - messageId: result.receiverId, - senderId: general.id, + if (intervalSeconds > 0) { + const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`; + const acquired = await ctx.redis.set(rateLimitKey, '1', { + NX: true, + PX: intervalSeconds * 1000, }); - } catch { - // 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다. + if (acquired === null) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`, + }); + } } + const destGeneral = await ctx.db.general.findUnique({ + where: { id: input.mailbox }, + }); + if (!destGeneral) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: '존재하지 않는 유저입니다.', + }); + } + const [sourceNation, destNation] = await Promise.all([ + general.nationId > 0 + ? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } }) + : null, + destGeneral.nationId > 0 + ? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } }) + : null, + ]); + const sourcePermission = + sourceNation && general.nationId > 0 ? resolveNationPermission(general, sourceNation.meta, false) : -1; + const destPermission = + destNation && destGeneral.nationId > 0 + ? resolveNationPermission(destGeneral, destNation.meta, false) + : -1; + if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '외교권자끼리는 메시지를 보낼 수 없습니다.', + }); + } + dest = await buildTargetFromGeneral(ctx.db, destGeneral); + msgType = 'private'; + } else { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid mailbox.', + }); + } - return { msgType, msgId: result.receiverId }; - }), + const draft: MessageDraft = { + msgType, + src, + dest, + text: input.text, + time: now, + validUntil, + option: {}, + }; + + const result = await sendMessage( + { + insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft), + }, + draft + ); + + try { + await publishRealtimeEvent(ctx.redis, ctx.profile.name, { + type: 'messageCreated', + at: now.toISOString(), + mailbox: receiverMailbox, + msgType, + messageId: result.receiverId, + senderId: general.id, + }); + } catch { + // 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다. + } + + return { msgType, msgId: result.receiverId }; + }), }); diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index c147532..77abd65 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -9,6 +9,7 @@ import { TournamentStore } from '../../tournament/store.js'; import { buildTournamentKeys } from '../../tournament/keys.js'; import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; +import { loadCurrentGameTime } from '../../services/gameClock.js'; const hasAdminRole = (roles: string[], profileName: string): boolean => { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { @@ -474,6 +475,7 @@ export const tournamentRouter = router({ await Promise.all([store.setParticipants([]), store.setMatches([]), store.setBettingEntries([])]); + const gameTime = await loadCurrentGameTime(ctx.db); const nextState: TournamentState = { ...state, stage: 0, @@ -484,7 +486,7 @@ export const tournamentRouter = router({ rewardSettled: false, bettingCloseAt: undefined, participantsLockedAt: undefined, - nextAt: new Date().toISOString(), + nextAt: gameTime.now.toISOString(), }; await store.setState(nextState); return { ok: true }; @@ -506,7 +508,8 @@ export const tournamentRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' }); } const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0; - if (closeAt && closeAt <= Date.now()) { + const gameNow = (await loadCurrentGameTime(ctx.db)).now.getTime(); + if (closeAt && closeAt <= gameNow) { throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' }); } diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 249b9f5..1ea73de 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -18,6 +18,7 @@ import { import { authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; +import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; const hasAdminRole = (roles: string[], profileName: string): boolean => { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { @@ -133,9 +134,26 @@ type VotePollRow = { opener_name: string; start_at: Date; end_at: Date | null; + end_tick: bigint | null; closed_at: Date | null; }; +const hasPollEnded = (poll: Pick, 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 <= time.now)); + +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; + } +}; + type VoteListRow = { id: number; title: string; @@ -215,6 +233,7 @@ export const voteRouter = router({ opener_name, start_at, end_at, + end_tick, closed_at FROM vote_poll WHERE id = ${input.voteId} @@ -226,7 +245,8 @@ export const voteRouter = router({ } const options = parseOptions(row.options); - const pollEnded = Boolean(row.closed_at) || (row.end_at ? row.end_at <= new Date() : false); + const gameTime = await loadCurrentGameTime(ctx.db); + const pollEnded = hasPollEnded(row, gameTime); const userId = ctx.auth?.user.id; const general = userId ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) : null; @@ -330,6 +350,7 @@ export const voteRouter = router({ opener_name, start_at, end_at, + end_tick, closed_at FROM vote_poll WHERE id = ${input.voteId} @@ -339,7 +360,8 @@ export const voteRouter = router({ if (!poll) { throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); } - if (poll.closed_at || (poll.end_at && poll.end_at < new Date())) { + const gameTime = await loadCurrentGameTime(ctx.db); + if (hasPollEnded(poll, gameTime)) { throw new TRPCError({ code: 'BAD_REQUEST', message: '설문조사가 종료되었습니다.' }); } @@ -536,7 +558,8 @@ export const voteRouter = router({ if (endAt && Number.isNaN(endAt.getTime())) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' }); } - if (endAt && endAt < new Date()) { + const gameTime = await loadCurrentGameTime(ctx.db); + if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } @@ -551,7 +574,7 @@ export const voteRouter = router({ if (input.closePrevious) { await ctx.db.$queryRaw(GamePrisma.sql` UPDATE vote_poll - SET closed_at = NOW(), updated_at = NOW() + SET closed_at = ${gameTime.now}, updated_at = NOW() WHERE closed_at IS NULL `); } @@ -566,7 +589,9 @@ export const voteRouter = router({ opener_general_id, opener_name, start_at, - end_at + start_tick, + end_at, + end_tick ) VALUES ( ${input.title}, @@ -576,8 +601,10 @@ export const voteRouter = router({ ${input.revealMode}, ${general.id}, ${general.name}, - NOW(), - ${endAt} + ${gameTime.now}, + ${gameTime.tick === null ? null : BigInt(gameTime.tick)}, + ${endAt}, + ${toGameTickOrNull(gameTime, endAt)} ) `); @@ -608,6 +635,7 @@ export const voteRouter = router({ opener_name, start_at, end_at, + end_tick, closed_at FROM vote_poll WHERE id = ${input.voteId} @@ -646,7 +674,8 @@ export const voteRouter = router({ if (endAt && Number.isNaN(endAt.getTime())) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' }); } - if (endAt && endAt < new Date()) { + const gameTime = await loadCurrentGameTime(ctx.db); + if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } @@ -670,6 +699,7 @@ export const voteRouter = router({ multiple_options = COALESCE(${nextMultipleOptions}, multiple_options), reveal_mode = COALESCE(${input.revealMode}, reveal_mode), end_at = ${endAt ?? poll.end_at}, + end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick}, updated_at = NOW() WHERE id = ${input.voteId} `); @@ -681,7 +711,7 @@ export const voteRouter = router({ .mutation(async ({ ctx, input }) => { const rows = await ctx.db.$queryRaw>(GamePrisma.sql` UPDATE vote_poll - SET closed_at = NOW(), updated_at = NOW() + SET closed_at = ${(await loadCurrentGameTime(ctx.db)).now}, updated_at = NOW() WHERE id = ${input.voteId} RETURNING id `); diff --git a/app/game-api/src/services/gameClock.ts b/app/game-api/src/services/gameClock.ts new file mode 100644 index 0000000..d8862e9 --- /dev/null +++ b/app/game-api/src/services/gameClock.ts @@ -0,0 +1,48 @@ +import { GameClock, type GameClockMode } from '@sammo-ts/common'; + +import type { DatabaseClient } from '../context.js'; + +export interface CurrentGameTime { + now: Date; + tick: number | null; + mode: GameClockMode | null; + dateToTick(date: Date): number | null; +} + +export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date()): Promise => { + if (!db.worldState) { + return { now: wallNow, tick: null, mode: null, dateToTick: () => null }; + } + const state = await db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { + clockBaseTime: true, + clockTick: true, + clockMode: true, + clockWallAnchor: true, + tickSeconds: true, + }, + }); + if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) { + return { now: wallNow, tick: null, mode: null, dateToTick: () => null }; + } + const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime'; + 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 clock = new GameClock({ + baseTime: state.clockBaseTime, + tick: storedTick, + mode, + wallAnchor: state.clockWallAnchor, + turnSeconds: state.tickSeconds, + }); + const tick = clock.nowTick(wallNow); + return { + now: clock.tickToDate(tick), + tick, + mode, + dateToTick: (date) => clock.dateToTick(date), + }; +}; diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 4530c6b..7fb6c2f 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -11,6 +11,7 @@ import { import { resolveGameApiConfigFromEnv } from '../config.js'; 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 type { TurnDaemonTransport } from '../daemon/transport.js'; import { buildTournamentKeys } from './keys.js'; @@ -161,7 +162,8 @@ export const applyPreBattleStage = async ( prisma: TournamentPrismaClient, state: TournamentState, baseSeed: string, - daemonTransport: TurnDaemonTransport + daemonTransport: TurnDaemonTransport, + now: () => number = Date.now ): Promise => { const participants = await store.getParticipants(); @@ -190,7 +192,7 @@ export const applyPreBattleStage = async ( ...state, stage: 2, phase: 0, - participantsLockedAt: new Date().toISOString(), + participantsLockedAt: new Date(now()).toISOString(), nextAt: resolveNextAt(state), }; await store.setState(nextState); @@ -418,7 +420,7 @@ export const applyPreBattleStage = async ( ...state, stage: 6, phase: 0, - bettingId: state.bettingId ?? Date.now(), + bettingId: state.bettingId ?? now(), bettingCloseAt: resolveBettingCloseAt(state), nextAt: resolveNextAt(state), }; @@ -430,7 +432,7 @@ export const applyPreBattleStage = async ( if (state.stage === 6) { const bettingCloseAt = state.bettingCloseAt ?? resolveBettingCloseAt(state); const bettingCloseMs = new Date(bettingCloseAt).getTime(); - if (Number.isFinite(bettingCloseMs) && bettingCloseMs > Date.now()) { + if (Number.isFinite(bettingCloseMs) && bettingCloseMs > now()) { const waitingState: TournamentState = { ...state, bettingCloseAt, @@ -578,7 +580,7 @@ export const processTournamentTick = async (options: { 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); + nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now); } processedState = (await settleTournamentOutcome({ @@ -620,9 +622,9 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {}) } const nextAt = new Date(state.nextAt).getTime(); - const now = Date.now(); - if (state.auto && Number.isFinite(nextAt) && nextAt > now) { - await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - now)); + 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)); continue; } @@ -631,6 +633,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {}) store, prisma: postgres.prisma, daemonTransport, + now: () => gameNow, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/app/game-api/test/auctionWorker.test.ts b/app/game-api/test/auctionWorker.test.ts index 3282374..302b5be 100644 --- a/app/game-api/test/auctionWorker.test.ts +++ b/app/game-api/test/auctionWorker.test.ts @@ -14,7 +14,11 @@ const buildRedis = () => ({ const buildDb = (options: { updated: number; - auction?: { status: 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED'; closeAt: Date } | null; + auction?: { + status: 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED'; + closeAt: Date; + closeTick?: bigint | null; + } | null; existingEvents?: Array<{ requestId: string; target: 'ENGINE'; @@ -69,6 +73,29 @@ describe('auction worker clock-shift race', () => { expect(transaction.inputEvent.create).not.toHaveBeenCalled(); }); + it('requeues a tick-backed auction using its logical deadline score', async () => { + const redis = buildRedis(); + const closeAt = new Date('2099-01-01T00:00:00.000Z'); + const { db } = buildDb({ + updated: 0, + auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n }, + }); + + await expect( + processDueAuctionId({ + db, + redis, + timerKey: 'timer', + historyKey: 'history', + id: '7', + nowMs: new Date('2042-01-01T00:00:00.000Z').getTime(), + nowTick: 36_000_000, + }) + ).resolves.toBe('RESCHEDULED'); + + expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]); + }); + it('commits the FINALIZING transition and durable command in one transaction before recording history', async () => { const redis = buildRedis(); const closeAt = new Date('2026-07-30T11:00:00.000Z'); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index fbe4397..87015e5 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -220,6 +220,25 @@ describe('tournament worker schedule compatibility', () => { expect(resolveNextAt(state)).toBe('2026-08-02T10:10:00.000Z'); expect(resolveBettingCloseAt(state)).toBe('2026-08-02T11:00:00.000Z'); }); + + it('uses the injected game clock, not the host wall clock, to close betting', async () => { + const redis = new MemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys('game-clock-deadline')); + const bettingCloseAt = '2099-01-01T00:00:00.000Z'; + const state = createTournamentState({ stage: 6, bettingCloseAt, nextAt: bettingCloseAt }); + await store.setState(state); + + const next = await applyPreBattleStage( + store, + createPrismaMock({ baseSeed: 'clock-seed' }), + state, + 'clock-seed', + createNoopDaemonTransport(), + () => new Date('2100-01-01T00:00:00.000Z').getTime() + ); + + expect(next).toMatchObject({ stage: 7, bettingCloseAt }); + }); }); describe('tournament worker (in-memory)', () => { diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index 9893586..dd07b0d 100644 --- a/app/game-engine/src/auction/bidder.ts +++ b/app/game-engine/src/auction/bidder.ts @@ -192,7 +192,7 @@ export const createAuctionBidder = async (options: { reason: '경매가 종료되었습니다.', }; } - const now = new Date(); + const now = world.getGameNow(new Date()); if (auction.closeAt.getTime() <= now.getTime()) { return { type: 'auctionBid', @@ -379,6 +379,7 @@ export const createAuctionBidder = async (options: { GamePrisma.sql` UPDATE auction SET close_at = ${nextCloseAt}, + close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, latest_event_id = ${eventId}, latest_event_at = ${eventAt}, updated_at = ${eventAt} diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts index 0d21cf5..04cde22 100644 --- a/app/game-engine/src/auction/finalizer.ts +++ b/app/game-engine/src/auction/finalizer.ts @@ -1,12 +1,5 @@ import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; -import { - ActionLogger, - ItemLoader, - LogFormat, - UserLogger, - isItemKey, - resolveUniqueConfig, -} from '@sammo-ts/logic'; +import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey, resolveUniqueConfig } from '@sammo-ts/logic'; import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js'; import { asRecord, JosaUtil } from '@sammo-ts/common'; @@ -180,7 +173,7 @@ export const createAuctionFinalizer = async (options: { ); const highestBid = bidRows[0] ?? null; - const now = new Date(); + const now = world.getGameNow(new Date()); const logs: LogEntryDraft[] = []; const globalLogger = new ActionLogger(); @@ -244,9 +237,7 @@ export const createAuctionFinalizer = async (options: { const remainExtension = detail.remainCloseDateExtensionCnt ?? 0; if (bidMeta.tryExtendCloseDate === true && remainExtension > 0) { const turnMinutes = await resolveTurnMinutes(db); - const nextCloseAt = new Date( - auction.closeAt.getTime() + Math.max(5, turnMinutes) * 60_000 - ); + const nextCloseAt = new Date(auction.closeAt.getTime() + Math.max(5, turnMinutes) * 60_000); const nextLatestBidCloseAt = new Date( nextCloseAt.getTime() + Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) * @@ -263,6 +254,7 @@ export const createAuctionFinalizer = async (options: { SET status = 'OPEN', detail = ${JSON.stringify(nextDetail)}::jsonb, close_at = ${nextCloseAt}, + close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, updated_at = ${now} WHERE id = ${auctionId} ` @@ -419,6 +411,7 @@ export const createAuctionFinalizer = async (options: { host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'}, detail = ${JSON.stringify(nextDetail)}::jsonb, close_at = ${nextCloseAt}, + close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, updated_at = ${now} WHERE id = ${auctionId} ` @@ -449,10 +442,7 @@ export const createAuctionFinalizer = async (options: { ); const nextLatestBidCloseAt = new Date( nextCloseAt.getTime() + - Math.max( - MIN_EXTENSION_MINUTES_PER_BID, - turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID - ) * + Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) * 60_000 ); const nextDetail = { @@ -467,6 +457,7 @@ export const createAuctionFinalizer = async (options: { host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'}, detail = ${JSON.stringify(nextDetail)}::jsonb, close_at = ${nextCloseAt}, + close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, updated_at = ${now} WHERE id = ${auctionId} ` diff --git a/app/game-engine/src/auction/opener.ts b/app/game-engine/src/auction/opener.ts index dda38ef..c140638 100644 --- a/app/game-engine/src/auction/opener.ts +++ b/app/game-engine/src/auction/opener.ts @@ -94,7 +94,7 @@ const openResourceAuction = async ( return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`); } - const now = new Date(); + const now = world.getGameNow(new Date()); 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,6 +113,8 @@ const openResourceAuction = async ( }, status: 'OPEN', closeAt, + openTick: BigInt(world.dateToGameTick(now)), + closeTick: BigInt(world.dateToGameTick(closeAt)), }, }); world.updateGeneral(general.id, { @@ -218,7 +220,7 @@ const openUniqueAuction = async ( const state = world.getState(); const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60)); - const now = new Date(); + const now = world.getGameNow(new Date()); 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( @@ -250,6 +252,8 @@ const openUniqueAuction = async ( }, status: 'OPEN', closeAt, + openTick: BigInt(world.dateToGameTick(now)), + closeTick: BigInt(world.dateToGameTick(closeAt)), latestEventId: eventId, latestEventAt: now, bids: { diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 877913a..f56702f 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -165,17 +165,33 @@ export class TurnDaemonLifecycle { } const nowMs = this.clock.nowMs(); + const gameClock = await this.stateStore.loadGameClock?.(new Date(nowMs)); + if (gameClock?.mode === 'manual') { + // Ref observes all generals due before one monthly boundary in + // a single snapshot. Manual mode advances directly to that + // boundary instead of letting sub-minute general timestamps + // alter command/RNG order. After a restart, drain only turns + // strictly older than the persisted game time before moving on. + const gameNowMs = gameClock.now.getTime(); + const hasOverdueGeneral = nextRunTime.getTime() < gameNowMs; + const targetTime = hasOverdueGeneral + ? new Date(gameNowMs - 1) + : this.getNextTickTime(new Date(this.status.lastTurnTime!)); + await this.runOnce({ reason: 'schedule', targetTime }); + continue; + } + const gameNowMs = gameClock?.now.getTime() ?? nowMs; const nextTurnMs = nextRunTime.getTime(); - if (nowMs >= nextTurnMs) { + if (gameNowMs >= nextTurnMs) { // Ref checkDelay() executes every turn due at the observed // wall-clock time in one snapshot. Using only the oldest due // timestamp lets generals created by that batch run before a // monthly boundary, although Ref defers them to the next pass. - await this.runOnce({ reason: 'schedule', targetTime: new Date(nowMs) }); + await this.runOnce({ reason: 'schedule', targetTime: new Date(gameNowMs) }); continue; } - const command = await this.controlQueue.waitUntil(nextTurnMs); + const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs)); if (command) { await this.handleCommand(command); } @@ -354,6 +370,7 @@ export class TurnDaemonLifecycle { try { const runAndFlush = async (): Promise => { + await this.stateStore.advanceGameClockTo?.(targetTime, new Date(startMs)); const nextResult = await this.processor.run(targetTime, budget, checkpoint); fallbackError = 'Unknown turn flush error.'; this.status.state = 'flushing'; diff --git a/app/game-engine/src/lifecycle/types.ts b/app/game-engine/src/lifecycle/types.ts index a29ce45..8389d78 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -7,6 +7,7 @@ import type { TurnRunResult, } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; +import type { GameClockMode } from '@sammo-ts/common'; export type { RunReason, @@ -51,6 +52,8 @@ export interface TurnStateStore { saveLastTurnTime(turnTime: Date): Promise; loadCheckpoint(): Promise; saveCheckpoint(checkpoint?: TurnCheckpoint): Promise; + loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>; + advanceGameClockTo?(target: Date, wallNow: Date): Promise; } export interface TurnDaemonControlQueue { diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 756268b..7cad179 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 { asNumber, asRecord } from '@sammo-ts/common'; +import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common'; import { buildScenarioBootstrap, resolveScenarioGeneralDeathMonth, @@ -66,6 +66,7 @@ export interface ScenarioSeedOptions { resetTables?: boolean; now?: Date; tickSeconds?: number; + gameClockMode?: GameClockMode; installOptions?: ScenarioInstallOptions; includeNeutralNationInSeed?: boolean; defaultGeneralGold?: number; @@ -217,6 +218,15 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60)); const sync = install?.sync ?? false; const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync); + const gameClockMode = options.gameClockMode ?? 'realtime'; + const initialClock = new GameClock({ + baseTime: startState.startTime, + tick: 0, + mode: gameClockMode, + wallAnchor: now, + turnSeconds: tickSeconds, + }); + const initialClockTick = initialClock.dateToTick(now); const { seed, warnings } = buildScenarioBootstrap({ scenario: scenarioDefinition, @@ -367,6 +377,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom currentYear: startState.currentYear, currentMonth: startState.currentMonth, tickSeconds, + clockBaseTime: initialClock.baseTime, + clockTick: BigInt(initialClockTick), + clockMode: gameClockMode, + clockWallAnchor: now, + lastTurnTick: BigInt(initialClockTick), config: asJson({ ...scenarioConfig, ...worldConfig }), meta: asJson(worldMeta), }, @@ -523,6 +538,18 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom : 0) / 1_000 ) ), + turnTick: BigInt( + initialClock.dateToTick( + new Date( + now.getTime() + + Math.floor( + (typeof general.meta.initialTurnOffsetMicros === 'number' + ? general.meta.initialTurnOffsetMicros + : 0) / 1_000 + ) + ) + ) + ), age: resolveGeneralAge(startState.currentYear, general.birthYear), // Legacy GeneralBuilder leaves startage at the schema default on install. startAge: 20, diff --git a/app/game-engine/src/turn/cli.ts b/app/game-engine/src/turn/cli.ts index 83695ef..ca67a99 100644 --- a/app/game-engine/src/turn/cli.ts +++ b/app/game-engine/src/turn/cli.ts @@ -1,5 +1,5 @@ import type { TurnSchedule } from '@sammo-ts/logic'; -import { parseOptionalBoolean, parseOptionalNumber } from '@sammo-ts/common'; +import { parseOptionalBoolean, parseOptionalNumber, type GameClockMode } from '@sammo-ts/common'; import type { TurnRunBudget } from '../lifecycle/types.js'; import { resolveDatabaseUrl } from '../scenario/databaseUrl.js'; @@ -16,6 +16,7 @@ export interface TurnDaemonCliOptions { budget?: Partial; enableDatabaseFlush?: boolean; adminActionIntervalMs?: number; + gameClockMode?: GameClockMode; env?: NodeJS.ProcessEnv; } @@ -58,6 +59,11 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom const enableDatabaseFlush = options.enableDatabaseFlush ?? parseOptionalBoolean(env.TURN_FLUSH_DB) ?? true; const pauseGateIntervalMs = parseOptionalNumber(env.TURN_PAUSE_GATE_MS); const adminActionIntervalMs = options.adminActionIntervalMs ?? parseOptionalNumber(env.TURN_ADMIN_ACTION_MS); + const rawGameClockMode = options.gameClockMode ?? env.GAME_CLOCK_MODE; + if (rawGameClockMode && rawGameClockMode !== 'realtime' && rawGameClockMode !== 'manual') { + throw new Error(`GAME_CLOCK_MODE must be realtime or manual: ${rawGameClockMode}`); + } + const gameClockMode = rawGameClockMode as GameClockMode | undefined; const runtime = await createTurnDaemonRuntime({ profile, @@ -70,6 +76,7 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom enableDatabaseFlush, pauseGateIntervalMs, adminActionIntervalMs, + gameClockMode, }); let closed = false; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 8d25dbd..ac0aa02 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -374,7 +374,10 @@ const buildGeneralUpdate = ( penalty: asJson(general.penalty ?? {}), meta: buildPersistedGeneralMeta(general), turnTime: general.turnTime, + turnTick: BigInt(general.turnTick ?? 0), recentWarTime: general.recentWarTime ?? null, + recentWarTick: + general.recentWarTick === null || general.recentWarTick === undefined ? null : BigInt(general.recentWarTick), }); const buildGeneralCreate = ( @@ -418,7 +421,10 @@ const buildGeneralCreate = ( penalty: asJson(general.penalty ?? {}), meta: buildPersistedGeneralMeta(general), turnTime: general.turnTime, + turnTick: BigInt(general.turnTick ?? 0), recentWarTime: general.recentWarTime ?? null, + recentWarTick: + general.recentWarTick === null || general.recentWarTick === undefined ? null : BigInt(general.recentWarTick), }); const buildCityUpdate = ( @@ -601,6 +607,11 @@ export const createDatabaseTurnHooks = async ( currentYear: state.currentYear, currentMonth: state.currentMonth, tickSeconds: state.tickSeconds, + clockBaseTime: state.clockBaseTime ?? state.lastTurnTime, + clockTick: BigInt(state.clockTick ?? 0), + clockMode: state.clockMode ?? 'manual', + clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime, + lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)), meta: asJson(state.meta), }; const persist = async (prisma: GamePrisma.TransactionClient): Promise => { @@ -649,7 +660,8 @@ export const createDatabaseTurnHooks = async ( prisma, lifecycleEvents, meta, - asRecord(world.getScenarioConfig().const) + asRecord(world.getScenarioConfig().const), + world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0) ); if (inheritancePointAdjustments.length > 0) { @@ -732,6 +744,8 @@ export const createDatabaseTurnHooks = async ( hostName: auction.hostName, detail: asJson(auction.detail), status: 'OPEN', + openTick: BigInt(state.clockTick ?? world.dateToGameTick(state.lastTurnTime)), + closeTick: BigInt(world.dateToGameTick(auction.closeAt)), closeAt: auction.closeAt, })), }); @@ -959,15 +973,29 @@ 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, valid_until, message) + 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 diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts index ca60eae..c072c58 100644 --- a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -157,7 +157,8 @@ const computeRate = (numerator: number, denominator: number): number => (denomin const settleHall = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent, - worldMeta: Record + worldMeta: Record, + gameNow: Date ): Promise => { const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0)); if (isUnited !== 0) { @@ -207,7 +208,7 @@ const settleHall = async ( bgColor: nation?.color ?? '#000000', fgColor: resolveLegacyTextColor(nation?.color ?? '#000000'), startTime: typeof worldMeta.starttime === 'string' ? worldMeta.starttime : null, - unitedTime: new Date().toISOString(), + unitedTime: gameNow.toISOString(), ownerDisplayName: typeof asRecord(event.before.meta).ownerDisplayName === 'string' ? asRecord(event.before.meta).ownerDisplayName @@ -329,7 +330,8 @@ export const persistGeneralLifecycleEvents = async ( prisma: GamePrisma.TransactionClient, events: GeneralLifecycleEvent[], worldMeta: Record, - configConst: Record + configConst: Record, + gameNow = new Date() ): Promise => { if (events.length === 0) { return; @@ -348,7 +350,7 @@ export const persistGeneralLifecycleEvents = async ( await settleInheritance(prisma, event, worldMeta, false, configConst); } if (event.outcome === 'retired') { - await settleHall(prisma, event, worldMeta); + await settleHall(prisma, event, worldMeta, gameNow); await settleInheritance(prisma, event, worldMeta, true, configConst); await prisma.rankData.updateMany({ where: { generalId: event.generalId }, diff --git a/app/game-engine/src/turn/inMemoryStateStore.ts b/app/game-engine/src/turn/inMemoryStateStore.ts index cdf7362..2d7012c 100644 --- a/app/game-engine/src/turn/inMemoryStateStore.ts +++ b/app/game-engine/src/turn/inMemoryStateStore.ts @@ -28,4 +28,15 @@ export class InMemoryTurnStateStore implements TurnStateStore { async saveCheckpoint(checkpoint?: TurnCheckpoint): Promise { this.world.setCheckpoint(checkpoint); } + + async loadGameClock(wallNow = new Date(Date.now())): Promise<{ mode: 'realtime' | 'manual'; now: Date }> { + return { + mode: this.world.getGameClockState().mode, + now: this.world.getGameNow(wallNow), + }; + } + + async advanceGameClockTo(target: Date, wallNow: Date): Promise { + this.world.advanceGameClockTo(target, wallNow); + } } diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index c851d84..1437bbf 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -62,12 +62,12 @@ export class InMemoryTurnProcessor implements TurnProcessor { // Ref processes `turntime < monthlyBoundary` before the monthly turn. A // general exactly on the boundary therefore runs only after that month // has advanced, on the daemon's following pass. - const useStrictGeneralCutoff = - firstTickTime.getTime() === targetTime.getTime() || targetTime.getTime() <= previousLastTurnTime.getTime(); - const generalCutoff = - useStrictGeneralCutoff - ? new Date(targetTime.getTime() - 1) - : targetTime; + // The monthly boundary itself stays strict (`turn_time < boundary`) like + // Ref. A manual clock may instead target an overdue general whose time + // is older than lastTurnTime; that exact general must be included or the + // daemon would repeatedly flush an empty run without advancing. + const useStrictGeneralCutoff = firstTickTime.getTime() === targetTime.getTime(); + const generalCutoff = useStrictGeneralCutoff ? new Date(targetTime.getTime() - 1) : targetTime; const dueGenerals = this.world.listDueGenerals(generalCutoff, checkpoint); for (const general of dueGenerals) { if (processedGenerals >= budget.maxGenerals || isBudgetExpired()) { diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 02fb35d..85b3306 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -9,6 +9,7 @@ import type { UnitSetDefinition, } from '@sammo-ts/logic'; import { getNextTurnAt } from '@sammo-ts/logic'; +import { GameClock, type GameClockMode } from '@sammo-ts/common'; import type { TurnCheckpoint } from '../lifecycle/types.js'; import type { @@ -105,6 +106,14 @@ export interface InMemoryTurnWorldOptions { autoAdvanceDiplomacyMonth?: boolean; } +export interface InMemoryGameClockState { + baseTime: Date; + tick: number; + mode: GameClockMode; + wallAnchor: Date; + lastTurnTick: number; +} + export interface TurnWorldChanges { generals: TurnGeneral[]; cities: City[]; @@ -421,7 +430,36 @@ export class InMemoryTurnWorld { private state: TurnWorldState; constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) { - this.state = { ...state }; + const baseTime = new Date((state.clockBaseTime ?? state.lastTurnTime).getTime()); + const mode = state.clockMode ?? 'manual'; + const wallAnchor = new Date((state.clockWallAnchor ?? state.lastTurnTime).getTime()); + const bootstrapClock = new GameClock({ + baseTime, + tick: state.clockTick ?? 0, + mode, + wallAnchor, + turnSeconds: state.tickSeconds, + }); + const lastTurnTick = state.lastTurnTick ?? bootstrapClock.dateToTick(state.lastTurnTime); + const clockTick = state.clockTick ?? lastTurnTick; + const gameClock = new GameClock({ + baseTime, + tick: clockTick, + mode, + wallAnchor, + turnSeconds: state.tickSeconds, + }); + const lastTurnTime = gameClock.tickToDate(lastTurnTick); + this.state = { + ...state, + clockBaseTime: baseTime, + clockTick, + clockMode: mode, + clockWallAnchor: wallAnchor, + lastTurnTick, + lastTurnTime, + meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() }, + }; this.scenarioConfig = snapshot.scenarioConfig; this.unitSet = snapshot.unitSet; this.schedule = options.schedule; @@ -435,7 +473,9 @@ export class InMemoryTurnWorld { const worldKillturn = resolveWorldKillturn(this.state.meta); for (const general of snapshot.generals) { - const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime); + const normalized = this.normalizeGeneralClock( + normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime) + ); const ensured = ensureGeneralKillturn(normalized, worldKillturn); this.generals.set(general.id, ensured); } @@ -461,6 +501,67 @@ export class InMemoryTurnWorld { this.ensureDiplomacyMatrix(); } + private getGameClock(): GameClock { + return new GameClock({ + baseTime: this.state.clockBaseTime ?? this.state.lastTurnTime, + tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0, + mode: this.state.clockMode ?? 'manual', + wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, + turnSeconds: this.state.tickSeconds, + }); + } + + private normalizeGeneralClock(general: TurnGeneral): TurnGeneral { + const clock = this.getGameClock(); + const turnTick = general.turnTick ?? clock.dateToTick(general.turnTime); + const recentWarTick = + general.recentWarTick !== undefined + ? general.recentWarTick + : general.recentWarTime + ? clock.dateToTick(general.recentWarTime) + : null; + return { + ...general, + turnTick, + turnTime: clock.tickToDate(turnTick), + recentWarTick, + recentWarTime: recentWarTick === null ? null : clock.tickToDate(recentWarTick), + }; + } + + getGameClockState(): InMemoryGameClockState { + return { + baseTime: new Date((this.state.clockBaseTime ?? this.state.lastTurnTime).getTime()), + tick: this.state.clockTick ?? 0, + mode: this.state.clockMode ?? 'manual', + wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()), + lastTurnTick: this.state.lastTurnTick ?? 0, + }; + } + + getGameNow(wallNow: Date): Date { + return this.getGameClock().now(wallNow); + } + + dateToGameTick(date: Date): number { + return this.getGameClock().dateToTick(date); + } + + gameTickToDate(tick: number): Date { + return this.getGameClock().tickToDate(tick); + } + + advanceGameClockTo(target: Date, wallNow: Date): void { + const clock = this.getGameClock(); + const targetTick = clock.dateToTick(target); + const nextTick = Math.max(clock.tick, targetTick); + this.state = { + ...this.state, + clockTick: nextTick, + clockWallAnchor: new Date(wallNow.getTime()), + }; + } + captureState(): InMemoryTurnWorldStateSnapshot { return structuredClone({ schedule: this.schedule, @@ -573,17 +674,31 @@ export class InMemoryTurnWorld { if (previousTickSeconds === nextTickSeconds) { return; } + const previousClock = this.getGameClock(); + const anchorTick = this.state.clockTick ?? previousClock.tick; + const anchorDisplay = previousClock.tickToDate(anchorTick); + const nextBaseTime = GameClock.baseTimeForProjection(anchorDisplay, anchorTick, nextTickSeconds); const ratio = nextTickSeconds / previousTickSeconds; const baseTime = this.state.lastTurnTime.getTime(); - for (const general of this.generals.values()) { - const nextTurnTime = new Date(baseTime + (general.turnTime.getTime() - baseTime) * ratio); - this.updateGeneral(general.id, { turnTime: nextTurnTime }); - } + const nextGeneralTimes = new Map( + Array.from(this.generals.values(), (general) => [ + general.id, + new Date(baseTime + (general.turnTime.getTime() - baseTime) * ratio), + ]) + ); this.schedule = { entries: [{ startMinute: 0, tickMinutes }] }; this.state = { ...this.state, tickSeconds: nextTickSeconds, + clockBaseTime: nextBaseTime, }; + for (const general of this.generals.values()) { + const nextTurnTime = nextGeneralTimes.get(general.id); + if (!nextTurnTime) { + throw new Error(`Missing projected turn time for general ${general.id}.`); + } + this.updateGeneral(general.id, { turnTime: nextTurnTime }); + } } pushLog(entry: LogEntryDraft): void { @@ -739,7 +854,15 @@ export class InMemoryTurnWorld { if (!target) { return null; } - const next = applyGeneralPatch(target, patch); + const next = this.normalizeGeneralClock( + applyGeneralPatch(target, { + ...patch, + ...(patch.turnTime && patch.turnTick === undefined ? { turnTick: undefined } : {}), + ...(patch.recentWarTime !== undefined && patch.recentWarTick === undefined + ? { recentWarTick: undefined } + : {}), + }) + ); this.generals.set(id, next); this.dirtyGeneralIds.add(id); return next; @@ -750,7 +873,9 @@ export class InMemoryTurnWorld { return false; } const worldKillturn = resolveWorldKillturn(this.state.meta); - const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime); + const normalized = this.normalizeGeneralClock( + normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime) + ); const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn)); this.generals.set(general.id, ensured); this.dirtyGeneralIds.add(general.id); @@ -882,18 +1007,23 @@ export class InMemoryTurnWorld { } setLastTurnTime(turnTime: Date): void { + const clock = this.getGameClock(); + const requestedTick = clock.dateToTick(turnTime); + const lastTurnTick = Math.max(this.state.lastTurnTick ?? requestedTick, requestedTick); + const projectedTime = clock.tickToDate(lastTurnTick); const meta = { ...this.state.meta, - lastTurnTime: turnTime.toISOString(), + lastTurnTime: projectedTime.toISOString(), }; this.state = { ...this.state, - lastTurnTime: new Date(turnTime.getTime()), + lastTurnTick, + lastTurnTime: projectedTime, meta, }; } - shiftSchedule(deltaMinutes: number): { shiftedGenerals: number; lastTurnTime: string } { + shiftSchedule(deltaMinutes: number, wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } { if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) { throw new Error('Schedule shift must be a non-zero integer number of minutes.'); } @@ -931,7 +1061,27 @@ export class InMemoryTurnWorld { ); }; - const nextLastTurnTime = shiftDate(this.state.lastTurnTime); + const previousClock = this.getGameClock(); + const generalTicks = new Map( + Array.from(this.generals.values(), (general) => [ + general.id, + { + turnTick: general.turnTick ?? previousClock.dateToTick(general.turnTime), + recentWarTick: + general.recentWarTick ?? + (general.recentWarTime ? previousClock.dateToTick(general.recentWarTime) : null), + }, + ]) + ); + const nextBaseTime = shiftDate(previousClock.baseTime); + const shiftedClock = new GameClock({ + baseTime: nextBaseTime, + tick: this.state.clockTick ?? 0, + mode: this.state.clockMode ?? 'manual', + wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, + turnSeconds: this.state.tickSeconds, + }); + const nextLastTurnTime = shiftedClock.tickToDate(this.state.lastTurnTick ?? 0); const nextMeta = { ...this.state.meta, lastTurnTime: nextLastTurnTime.toISOString(), @@ -941,12 +1091,27 @@ export class InMemoryTurnWorld { }; this.state = { ...this.state, + clockBaseTime: nextBaseTime, + // Rebasing is also the explicit resume checkpoint. Realtime mode + // must not replay the operational downtime after an administrator + // deliberately delays or accelerates the game schedule. + clockWallAnchor: new Date(wallNow.getTime()), lastTurnTime: nextLastTurnTime, meta: nextMeta, }; for (const general of this.generals.values()) { - this.updateGeneral(general.id, { turnTime: shiftDate(general.turnTime) }); + const ticks = generalTicks.get(general.id); + if (!ticks) { + throw new Error(`Missing captured game ticks for general ${general.id}.`); + } + const { turnTick, recentWarTick } = ticks; + this.updateGeneral(general.id, { + turnTick, + turnTime: shiftedClock.tickToDate(turnTick), + recentWarTick, + recentWarTime: recentWarTick === null ? null : shiftedClock.tickToDate(recentWarTick), + }); } for (const auction of this.pendingNeutralAuctions) { auction.closeAt = shiftDate(auction.closeAt); @@ -1055,10 +1220,13 @@ export class InMemoryTurnWorld { const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule); if (!result.deleted?.general) { - const nextGeneral = normalizeGeneralDatabaseIntegers({ - ...(result.general ?? currentGeneral), - turnTime: nextTurnAt, - }); + const nextGeneral = this.normalizeGeneralClock( + normalizeGeneralDatabaseIntegers({ + ...(result.general ?? currentGeneral), + turnTime: nextTurnAt, + turnTick: undefined, + }) + ); this.generals.set(nextGeneral.id, nextGeneral); this.dirtyGeneralIds.add(nextGeneral.id); } @@ -1083,8 +1251,17 @@ export class InMemoryTurnWorld { if (!target) { continue; } - const patched = applyGeneralPatch(target, patch.patch); - this.generals.set(patch.id, normalizeGeneralTurnTime(patched, this.state.lastTurnTime)); + const patched = applyGeneralPatch(target, { + ...patch.patch, + ...(patch.patch.turnTime && patch.patch.turnTick === undefined ? { turnTick: undefined } : {}), + ...(patch.patch.recentWarTime !== undefined && patch.patch.recentWarTick === undefined + ? { recentWarTick: undefined } + : {}), + }); + this.generals.set( + patch.id, + this.normalizeGeneralClock(normalizeGeneralTurnTime(patched, this.state.lastTurnTime)) + ); this.dirtyGeneralIds.add(patch.id); } for (const patch of result.patches.cities) { @@ -1127,7 +1304,9 @@ export class InMemoryTurnWorld { continue; } const worldKillturn = resolveWorldKillturn(this.state.meta); - const normalized = normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime); + const normalized = this.normalizeGeneralClock( + normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime) + ); const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn)); this.generals.set(createdGeneral.id, ensured); this.dirtyGeneralIds.add(createdGeneral.id); @@ -1195,15 +1374,20 @@ export class InMemoryTurnWorld { }; await this.calendarHandler?.beforeMonthChanged?.(context); + const clock = this.getGameClock(); + const requestedTick = clock.dateToTick(turnTime); + const lastTurnTick = Math.max(this.state.lastTurnTick ?? requestedTick, requestedTick); + const lastTurnTime = clock.tickToDate(lastTurnTick); const meta = { ...this.state.meta, - lastTurnTime: turnTime.toISOString(), + lastTurnTime: lastTurnTime.toISOString(), }; this.state = { ...this.state, currentYear: nextYear, currentMonth: nextMonth, - lastTurnTime: new Date(turnTime.getTime()), + lastTurnTick, + lastTurnTime, meta, }; diff --git a/app/game-engine/src/turn/monthlyNationBettingAction.ts b/app/game-engine/src/turn/monthlyNationBettingAction.ts index 4d6f0fe..4707f45 100644 --- a/app/game-engine/src/turn/monthlyNationBettingAction.ts +++ b/app/game-engine/src/turn/monthlyNationBettingAction.ts @@ -62,9 +62,7 @@ export const createOpenNationBettingHandler = (options: { const currentLastId = world.getState().meta.lastBettingId; const bettingId = - (typeof currentLastId === 'number' && Number.isFinite(currentLastId) - ? Math.trunc(currentLastId) - : 0) + 1; + (typeof currentLastId === 'number' && Number.isFinite(currentLastId) ? Math.trunc(currentLastId) : 0) + 1; world.updateWorldMeta({ lastBettingId: bettingId }); const shortName = nationCount === 1 ? '천통국' : `최후 ${nationCount}국`; @@ -88,10 +86,7 @@ export const createOpenNationBettingHandler = (options: { targetCode: 'DESTROY_NATION', priority: 1_000, condition: ['RemainNation', '<=', nationCount], - action: [ - ['FinishNationBetting', bettingId], - ['DeleteEvent'], - ], + action: [['FinishNationBetting', bettingId], ['DeleteEvent']], meta: {}, }) ) { @@ -109,7 +104,7 @@ export const createOpenNationBettingHandler = (options: { }); const text = `새로운 ${shortName} 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.`; - const now = new Date(); + const now = new Date(environment.turnTime.getTime()); for (const general of generals.filter((entry) => entry.npcState <= 1)) { const nation = world.getNationById(general.nationId); world.queueMessage({ diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 4138532..ac214de 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -154,6 +154,25 @@ export class InMemoryReservedTurnStore { } satisfies InMemoryReservedTurnStateSnapshot); } + /** + * Hot-path transaction savepoint. Queue mutations replace complete turn + * arrays and journal sets instead of mutating captured entries in place, + * so retaining those immutable references is sufficient for rollback. + * Public inspection snapshots remain deep clones via captureState(). + */ + captureTransactionState(): InMemoryReservedTurnStateSnapshot { + return { + generalTurns: Array.from(this.generalTurns.entries()), + nationTurns: Array.from(this.nationTurns.entries()), + dirtyGeneralIds: Array.from(this.dirtyGeneralIds), + dirtyNationKeys: Array.from(this.dirtyNationKeys), + pendingGeneralInitializationIds: Array.from(this.pendingGeneralInitializationIds), + pendingNationInitializationKeys: Array.from(this.pendingNationInitializationKeys), + leasedGeneralIds: Array.from(this.leasedGeneralIds), + leasedNationKeys: Array.from(this.leasedNationKeys), + }; + } + restoreState(snapshot: InMemoryReservedTurnStateSnapshot): void { const restored = structuredClone(snapshot); this.replaceMap(this.generalTurns, restored.generalTurns); @@ -232,11 +251,7 @@ export class InMemoryReservedTurnStore { let claimed = await revisionStore.updateMany({ where: { generalId, - OR: [ - { leaseOwner: this.leaseOwner }, - { leaseOwner: null }, - { leaseExpiresAt: { lte: now } }, - ], + OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }], }, data: { leaseOwner: this.leaseOwner, @@ -252,11 +267,7 @@ export class InMemoryReservedTurnStore { claimed = await revisionStore.updateMany({ where: { generalId, - OR: [ - { leaseOwner: this.leaseOwner }, - { leaseOwner: null }, - { leaseExpiresAt: { lte: now } }, - ], + OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }], }, data: { leaseOwner: this.leaseOwner, @@ -282,11 +293,7 @@ export class InMemoryReservedTurnStore { where: { nationId, officerLevel, - OR: [ - { leaseOwner: this.leaseOwner }, - { leaseOwner: null }, - { leaseExpiresAt: { lte: now } }, - ], + OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }], }, data: { leaseOwner: this.leaseOwner, @@ -303,11 +310,7 @@ export class InMemoryReservedTurnStore { where: { nationId, officerLevel, - OR: [ - { leaseOwner: this.leaseOwner }, - { leaseOwner: null }, - { leaseExpiresAt: { lte: now } }, - ], + OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }], }, data: { leaseOwner: this.leaseOwner, @@ -525,10 +528,7 @@ export class InMemoryReservedTurnStore { } } - private async claimGeneralFlushLease( - prisma: ReservedTurnDatabaseClient, - generalId: number - ): Promise { + private async claimGeneralFlushLease(prisma: ReservedTurnDatabaseClient, generalId: number): Promise { const revisionStore = prisma.generalTurnRevision; if (!revisionStore) { return false; @@ -538,11 +538,7 @@ export class InMemoryReservedTurnStore { ? { generalId, leaseOwner: this.leaseOwner } : { generalId, - OR: [ - { leaseOwner: this.leaseOwner }, - { leaseOwner: null }, - { leaseExpiresAt: { lte: new Date() } }, - ], + OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }], }; let claimed = await revisionStore.updateMany({ where, @@ -613,11 +609,7 @@ export class InMemoryReservedTurnStore { : { nationId, officerLevel, - OR: [ - { leaseOwner: this.leaseOwner }, - { leaseOwner: null }, - { leaseExpiresAt: { lte: new Date() } }, - ], + OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }], }; let claimed = await revisionStore.updateMany({ where, diff --git a/app/game-engine/src/turn/runtimeClockShift.ts b/app/game-engine/src/turn/runtimeClockShift.ts index 97f946a..58b0522 100644 --- a/app/game-engine/src/turn/runtimeClockShift.ts +++ b/app/game-engine/src/turn/runtimeClockShift.ts @@ -47,15 +47,18 @@ const syncAuctionTimers = async ( ): Promise => { const auctions = await db.auction.findMany({ where: { status: 'OPEN' }, - select: { id: true, closeAt: true }, + select: { id: true, closeAt: true, closeTick: true }, }); if (auctions.length > 0) { await redis.zAdd( `sammo:${profileName}:auction:timer`, - auctions.map((auction) => ({ - score: auction.closeAt.getTime(), - value: String(auction.id), - })) + auctions.map((auction) => { + const score = auction.closeTick == null ? auction.closeAt.getTime() : Number(auction.closeTick); + if (!Number.isSafeInteger(score)) { + throw new Error(`Auction ${auction.id} has an unsafe logical deadline: ${auction.closeTick}`); + } + return { score, value: String(auction.id) }; + }) ); } return auctions.length; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index c80b0d3..d594cdd 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,5 +1,5 @@ import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic'; -import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common'; +import { buildGameEventChannel, GameClock, type GameClockMode, type RealtimeEvent } from '@sammo-ts/common'; import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic'; @@ -87,6 +87,7 @@ export interface TurnDaemonRuntimeOptions { gatewayDatabaseUrl?: string; defaultBudget?: TurnRunBudget; clock?: Clock; + gameClockMode?: GameClockMode; controlQueue?: TurnDaemonControlQueue; schedule?: TurnSchedule; tickMinutes?: number; @@ -195,9 +196,31 @@ const createTurnDaemonRuntimeWithLease = async ( databaseUrl: options.databaseUrl, mapOptions: options.mapOptions, }); + const clock = options.clock ?? new SystemClock(); const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes); - const resolvedState = options.tickMinutes ? { ...state, tickSeconds: tickMinutes * 60 } : state; + const nextTickSeconds = tickMinutes * 60; + const tickSecondsChanged = options.tickMinutes !== undefined && nextTickSeconds !== state.tickSeconds; + const clockBaseTime = tickSecondsChanged + ? GameClock.baseTimeForProjection( + new GameClock({ + baseTime: state.clockBaseTime ?? state.lastTurnTime, + tick: state.clockTick ?? state.lastTurnTick ?? 0, + mode: state.clockMode ?? 'manual', + wallAnchor: state.clockWallAnchor ?? state.lastTurnTime, + turnSeconds: state.tickSeconds, + }).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0), + state.clockTick ?? state.lastTurnTick ?? 0, + nextTickSeconds + ) + : state.clockBaseTime; + const modeChanged = options.gameClockMode !== undefined && options.gameClockMode !== state.clockMode; + const resolvedState = { + ...state, + ...(options.tickMinutes ? { tickSeconds: nextTickSeconds, clockBaseTime } : {}), + ...(options.gameClockMode ? { clockMode: options.gameClockMode } : {}), + ...(modeChanged ? { clockWallAnchor: new Date(clock.nowMs()) } : {}), + }; const schedule = options.schedule ?? buildFixedSchedule(tickMinutes); const hasEventAction = (name: string): boolean => snapshot.events.some( @@ -219,6 +242,8 @@ const createTurnDaemonRuntimeWithLease = async ( ? null : await createReservedTurnStore({ databaseUrl: options.databaseUrl, + leaseOwner: options.leaseOwnerId, + leaseDurationMs: options.leaseDurationMs, }); const commandProfile = options.commandProfile ?? @@ -462,6 +487,7 @@ const createTurnDaemonRuntimeWithLease = async ( getWorldConfig: () => snapshot.worldConfig ?? null, getNationPowerRollCount: () => monthlyNationPowerRollCount, getTournamentRollConsumed: () => monthlyTournamentRollConsumed, + now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()), }); const tournamentAutoStartHandler = createTournamentAutoStartHandler({ profileName: options.profileName ?? options.profile, @@ -475,7 +501,7 @@ const createTurnDaemonRuntimeWithLease = async ( // Deterministic/manual runtimes must schedule the tournament from the // same clock that advances the game world. Production still falls // back to the system clock. - now: () => new Date(options.clock?.nowMs() ?? Date.now()), + now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()), }); const yearbookHandler = createYearbookHandler({ profileName: options.profileName ?? options.profile, @@ -537,7 +563,7 @@ const createTurnDaemonRuntimeWithLease = async ( }); if (reservedTurnStoreHandle) { stateManager.register('reservedTurns', { - capture: () => reservedTurnStoreHandle.store.captureState(), + capture: () => reservedTurnStoreHandle.store.captureTransactionState(), restore: (captured) => reservedTurnStoreHandle.store.restoreState(captured), inspect: () => reservedTurnStoreHandle.store.inspectState(), }); @@ -604,7 +630,6 @@ const createTurnDaemonRuntimeWithLease = async ( : undefined, }); const controlQueue = options.controlQueue ?? new InMemoryControlQueue(); - const clock = options.clock ?? new SystemClock(); let hooks: TurnDaemonHooks | undefined; let publishRealtimeEvent: ((event: RealtimeEvent) => Promise) | null = null; @@ -785,7 +810,11 @@ const createTurnDaemonRuntimeWithLease = async ( pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false), commandHandler, commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined), - stateManager, + // The exclusive fixture runner aborts the entire in-memory runtime + // on failure and has no concurrent writer. Avoid cloning the whole + // accumulated world before every due tick in that isolated mode; + // production and gateway-managed runtimes keep rollback savepoints. + stateManager: options.exclusiveFastForward ? undefined : stateManager, }, { profile: options.profile, defaultBudget } ); diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index a5673dc..aa6e8ca 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -10,6 +10,7 @@ import type { WorldSnapshot, GeneralLastTurn, } from '@sammo-ts/logic'; +import type { GameClockMode } from '@sammo-ts/common'; export interface TurnWorldState { id: number; @@ -17,6 +18,11 @@ export interface TurnWorldState { currentMonth: number; tickSeconds: number; lastTurnTime: Date; + clockBaseTime?: Date; + clockTick?: number; + clockMode?: GameClockMode; + clockWallAnchor?: Date; + lastTurnTick?: number; meta: Record; } @@ -29,7 +35,9 @@ export interface TurnGeneral extends General { picture?: string | null; imageServer?: number; turnTime: Date; + turnTick?: number; recentWarTime?: Date | null; + recentWarTick?: number | null; lastTurn?: GeneralLastTurn; penalty?: unknown; inheritancePoints?: Record; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 9124f81..99493cf 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -173,6 +173,26 @@ const resolveCommandAcceptedAt = async ( return event.createdAt; }; +const resolveOperationalAcceptedAt = async ( + db: DatabaseClient, + command: Pick +): Promise => { + if (!command.requestId) { + return new Date(); + } + const event = await db.inputEvent.findUnique({ + where: { requestId: command.requestId }, + select: { createdAt: true, target: true, eventType: true }, + }); + if (!event) { + throw new Error(`ENGINE input event ${command.requestId} is missing.`); + } + if (event.target !== 'ENGINE' || event.eventType !== command.type) { + throw new Error(`ENGINE input event type does not match ${command.type}.`); + } + return event.createdAt; +}; + const assertImmediateGeneralActionActor = async ( ctx: CommandHandlerContext, command: Extract, @@ -208,7 +228,8 @@ async function handleJoinCreateGeneral( if (!worldState) { throw new Error('Join world state is missing.'); } - const acceptedAt = await resolveCommandAcceptedAt(db, command); + const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); + const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt); try { return { type: 'joinCreateGeneral', @@ -272,7 +293,8 @@ async function handleNpcPossessGeneral( if (!worldState) { throw new Error('NPC possession world state is missing.'); } - const acceptedAt = await resolveCommandAcceptedAt(db, command); + const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); + const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt); try { return { type: 'npcPossessGeneral', @@ -313,7 +335,8 @@ async function handleSelectPoolCreate( if (!worldState) { throw new Error('Selection-pool world state is missing.'); } - const acceptedAt = await resolveCommandAcceptedAt(db, command); + const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); + const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt); try { return { type: 'selectPoolCreate', @@ -356,7 +379,8 @@ async function handleSelectPoolReselect( if (!worldState) { throw new Error('Selection-pool world state is missing.'); } - const acceptedAt = await resolveCommandAcceptedAt(db, command); + const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); + const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt); try { return { type: 'selectPoolReselect', @@ -832,7 +856,8 @@ async function handleShiftSchedule( }; } - const shifted = ctx.world.shiftSchedule(command.deltaMinutes); + const operationalAcceptedAt = await resolveOperationalAcceptedAt(ctx.commandDb, command); + const shifted = ctx.world.shiftSchedule(command.deltaMinutes, operationalAcceptedAt); const shiftedAuctions = await ctx.commandDb.$executeRaw( GamePrisma.sql` UPDATE auction @@ -841,6 +866,46 @@ async function handleShiftSchedule( WHERE status = 'OPEN' ` ); + await ctx.commandDb.$executeRaw( + GamePrisma.sql` + UPDATE select_pool + SET reserved_until = reserved_until + (${command.deltaMinutes} * INTERVAL '1 minute') + WHERE reserved_until IS NOT NULL + ` + ); + await ctx.commandDb.$executeRaw( + GamePrisma.sql` + UPDATE select_npc_token + SET valid_until = valid_until + (${command.deltaMinutes} * INTERVAL '1 minute'), + pick_more_from = pick_more_from + (${command.deltaMinutes} * INTERVAL '1 minute') + ` + ); + await ctx.commandDb.$executeRaw( + GamePrisma.sql` + UPDATE message + SET time = CASE WHEN time_tick IS NULL THEN time ELSE time + (${command.deltaMinutes} * INTERVAL '1 minute') END, + valid_until = CASE + WHEN valid_until_tick IS NULL THEN valid_until + ELSE valid_until + (${command.deltaMinutes} * INTERVAL '1 minute') + END + WHERE time_tick IS NOT NULL OR valid_until_tick IS NOT NULL + ` + ); + await ctx.commandDb.$executeRaw( + GamePrisma.sql` + UPDATE vote_poll + SET start_at = CASE WHEN start_tick IS NULL THEN start_at ELSE start_at + (${command.deltaMinutes} * INTERVAL '1 minute') END, + end_at = CASE + WHEN end_tick IS NULL THEN end_at + ELSE end_at + (${command.deltaMinutes} * INTERVAL '1 minute') + END, + closed_at = CASE + WHEN closed_at IS NULL THEN NULL + ELSE closed_at + (${command.deltaMinutes} * INTERVAL '1 minute') + END + WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL OR closed_at IS NOT NULL + ` + ); return { type: 'shiftSchedule', @@ -1809,7 +1874,7 @@ async function handleKick( src: messageTarget, dest: messageTarget, text, - time: new Date(), + time: world.getGameNow(new Date()), validUntil: new Date('9999-12-31T00:00:00.000Z'), option: {}, }); diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index e9613a7..533b50f 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -24,9 +24,8 @@ import type { import { normalizeScenarioEffect } from '@sammo-ts/logic'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { z } from 'zod'; -import { asRecord, isRecord } from '@sammo-ts/common'; +import { GameClock, asRecord, isRecord, type GameClockMode } from '@sammo-ts/common'; -import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import type { MapLoaderOptions } from '../scenario/mapLoader.js'; import { loadMapDefinitionByName } from '../scenario/mapLoader.js'; import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js'; @@ -78,6 +77,21 @@ const readMetaNumber = (meta: Record, key: string): number | nu return null; }; +const toSafeTick = (value: bigint, field: string): number => { + const tick = Number(value); + if (!Number.isSafeInteger(tick)) { + throw new Error(`${field} is outside the JavaScript safe integer range: ${value}`); + } + return tick; +}; + +const parseClockMode = (value: string): GameClockMode => { + if (value === 'realtime' || value === 'manual') { + return value; + } + throw new Error(`world_state.clock_mode is invalid: ${value}`); +}; + const zScenarioStatBlock = z.object({ total: z.number(), min: z.number(), @@ -130,38 +144,29 @@ const parseScenarioMeta = (meta: JsonRecord): ScenarioMeta | undefined => { return parsed.success ? parsed.data : undefined; }; -const parseLastTurnTime = (meta: JsonRecord): Date | null => { +const parseLegacyLastTurnTime = (meta: JsonRecord): Date | null => { const raw = meta.lastTurnTime; if (typeof raw !== 'string') { return null; } const parsed = new Date(raw); - if (Number.isNaN(parsed.getTime())) { - return null; - } - return parsed; + return Number.isNaN(parsed.getTime()) ? null : parsed; }; -const resolveFallbackTurnTimeBase = (generals: TurnGeneral[], updatedAt: Date | null): Date => { - let earliest: Date | null = null; - for (const general of generals) { - const turnTime = general.turnTime; - if (!earliest || turnTime.getTime() < earliest.getTime()) { - earliest = turnTime; - } +const resolveLegacyTurnTime = ( + generalRows: readonly TurnEngineGeneralRow[], + meta: JsonRecord, + updatedAt: Date | null | undefined +): Date => { + const stored = parseLegacyLastTurnTime(meta); + if (stored) { + return stored; } - if (earliest) { - return earliest; - } - if (updatedAt) { - return updatedAt; - } - return new Date(); -}; - -const alignToPreviousTick = (base: Date, tickMinutes: number): Date => { - const nextTick = getNextTickTime(base, tickMinutes); - return new Date(nextTick.getTime() - tickMinutes * 60_000); + const earliest = generalRows.reduce( + (result, row) => (!result || row.turnTime.getTime() < result.getTime() ? row.turnTime : result), + null + ); + return earliest ?? updatedAt ?? new Date(); }; const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => { @@ -180,6 +185,7 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => { const mapGeneralRow = ( row: TurnEngineGeneralRow, + gameClock: GameClock, rankRows: readonly TurnEngineRankDataRow[], inheritanceRows: readonly TurnEngineInheritancePointRow[], accessRow?: TurnEngineGeneralAccessLogRow @@ -248,8 +254,24 @@ const mapGeneralRow = ( lastTurn: normalizeGeneralLastTurn(row.lastTurn), penalty: row.penalty, // meta는 상단에서 보장 처리됨. - turnTime: row.turnTime, - recentWarTime: row.recentWarTime ?? null, + turnTick: + row.turnTick === null + ? gameClock.dateToTick(row.turnTime) + : toSafeTick(row.turnTick, `general.turn_tick(${row.id})`), + turnTime: + row.turnTick === null + ? row.turnTime + : gameClock.tickToDate(toSafeTick(row.turnTick, `general.turn_tick(${row.id})`)), + recentWarTick: + row.recentWarTick === null + ? row.recentWarTime + ? gameClock.dateToTick(row.recentWarTime) + : null + : toSafeTick(row.recentWarTick, `general.recent_war_tick(${row.id})`), + recentWarTime: + row.recentWarTick === null + ? (row.recentWarTime ?? null) + : gameClock.tickToDate(toSafeTick(row.recentWarTick, `general.recent_war_tick(${row.id})`)), inheritancePoints, ...(accessRow ? { refreshScoreTotal: accessRow.refreshScoreTotal } : {}), }; @@ -376,6 +398,35 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) }), ]); + const meta = asRecord(worldState.meta); + const legacyLastTurnTime = resolveLegacyTurnTime(generalRows, meta, worldState.updatedAt); + const hasPersistedClock = + worldState.clockBaseTime !== null && + worldState.clockTick !== null && + worldState.clockWallAnchor !== null && + worldState.lastTurnTick !== null; + const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual'; + const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime; + const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime; + const bootstrapClock = new GameClock({ + baseTime: clockBaseTime, + tick: 0, + mode: clockMode, + wallAnchor: clockWallAnchor, + turnSeconds: worldState.tickSeconds, + }); + const legacyLastTurnTick = bootstrapClock.dateToTick(legacyLastTurnTime); + const gameClock = new GameClock({ + baseTime: clockBaseTime, + tick: + worldState.clockTick === null + ? legacyLastTurnTick + : toSafeTick(worldState.clockTick, 'world_state.clock_tick'), + mode: clockMode, + wallAnchor: clockWallAnchor, + turnSeconds: worldState.tickSeconds, + }); + const ranksByGeneral = new Map(); for (const row of rankRows) { const bucket = ranksByGeneral.get(row.generalId) ?? []; @@ -396,6 +447,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) .map((row) => mapGeneralRow( row, + gameClock, ranksByGeneral.get(row.id) ?? [], row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [], accessByGeneral.get(row.id) @@ -406,10 +458,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) const nations = nationRows.map(mapNationRow).sort((left, right) => left.id - right.id); const diplomacy = diplomacyRows .map(mapDiplomacyRow) - .sort( - (left, right) => - left.fromNationId - right.fromNationId || left.toNationId - right.toNationId - ); + .sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId); const troops = troopRows.map(mapTroopRow).sort((left, right) => left.id - right.id); const worldConfig = asRecord(worldState.config); @@ -419,12 +468,13 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) const unitSetName = scenarioConfig.environment?.unitSet ?? 'che'; const unitSet = await loadUnitSetDefinitionByName(unitSetName, options.unitSetOptions); - const meta = asRecord(worldState.meta); const scenarioMeta = parseScenarioMeta(meta); - const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60)); - const fallbackBase = resolveFallbackTurnTimeBase(generals, worldState.updatedAt ?? null); - const lastTurnTime = parseLastTurnTime(meta) ?? alignToPreviousTick(fallbackBase, tickMinutes); + const lastTurnTick = + worldState.lastTurnTick === null + ? legacyLastTurnTick + : toSafeTick(worldState.lastTurnTick, 'world_state.last_turn_tick'); + const lastTurnTime = gameClock.tickToDate(lastTurnTick); const events = eventRows.filter((row) => row.targetCode !== 'initial').map(mapEventRow); const initialEvents = eventRows.filter((row) => row.targetCode === 'initial').map(mapEventRow); @@ -436,6 +486,11 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) currentMonth: worldState.currentMonth, tickSeconds: worldState.tickSeconds, lastTurnTime, + clockBaseTime: gameClock.baseTime, + clockTick: gameClock.tick, + clockMode, + clockWallAnchor: gameClock.wallAnchor, + lastTurnTick, meta, }, snapshot: { diff --git a/app/game-engine/test/engineStateManager.test.ts b/app/game-engine/test/engineStateManager.test.ts index 13703e7..b993fa1 100644 --- a/app/game-engine/test/engineStateManager.test.ts +++ b/app/game-engine/test/engineStateManager.test.ts @@ -257,7 +257,7 @@ describe('EngineStateManager', () => { store.replaceGeneralTurns(1, { action: '훈련', args: { amount: 10 } }); const manager = new EngineStateManager(); manager.register('reservedTurns', { - capture: () => store.captureState(), + capture: () => store.captureTransactionState(), restore: (snapshot) => store.restoreState(snapshot), }); const before = store.captureState(); diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index edc51d6..d75f8c2 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -36,7 +36,7 @@ const buildGeneral = (id: number, turnTime: string): TurnGeneral => npcState: 0, }) as TurnGeneral; -const buildWorld = (): InMemoryTurnWorld => { +const buildWorld = (stateOverride: Partial = {}): InMemoryTurnWorld => { const state: TurnWorldState = { id: 1, currentYear: 190, @@ -50,6 +50,7 @@ const buildWorld = (): InMemoryTurnWorld => { tnmt_time: '2026-07-30 11:30:00', untouched: 'keep', }, + ...stateOverride, }; const snapshot: TurnWorldSnapshot = { generals: [buildGeneral(1, '2026-07-30T10:10:00.000Z'), buildGeneral(2, '2026-07-30T10:20:00.000Z')], @@ -134,6 +135,29 @@ describe('runtime clock shift', () => { tnmt_time: '2026-07-30 11:15:00', }); }); + + it('rebases after two years of downtime without catching up missed turns', () => { + const wallAnchor = new Date('2026-07-30T10:00:00.000Z'); + const resumedAt = new Date('2028-07-29T10:00:00.000Z'); + const deltaMinutes = 2 * 365 * 24 * 60; + const world = buildWorld({ + clockBaseTime: wallAnchor, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: wallAnchor, + lastTurnTick: 0, + }); + + const beforeTurnTick = world.getGeneralById(1)?.turnTick; + world.shiftSchedule(deltaMinutes, resumedAt); + + expect(world.getGameNow(resumedAt).toISOString()).toBe('2028-07-29T10:00:00.000Z'); + expect(world.getGameNow(new Date(resumedAt.getTime() + 10 * 60_000)).toISOString()).toBe( + '2028-07-29T10:10:00.000Z' + ); + expect(world.getGeneralById(1)?.turnTick).toBe(beforeTurnTick); + expect(world.getGameClockState().wallAnchor).toEqual(resumedAt); + }); }); describe('runtime clock shift projection', () => { @@ -186,7 +210,9 @@ describe('runtime clock shift projection', () => { ), }, auction: { - findMany: vi.fn(async () => [{ id: 7, closeAt: new Date('2026-07-30T11:45:00.000Z') }]), + findMany: vi.fn(async () => [ + { id: 7, closeAt: new Date('2026-07-30T11:45:00.000Z'), closeTick: null }, + ]), }, } as unknown as GamePrismaClient; const values = new Map([ diff --git a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts index b8f9243..488682a 100644 --- a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts +++ b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { SystemClock } from '../src/lifecycle/clock.js'; import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js'; import { getNextTickTime } from '../src/lifecycle/getNextTickTime.js'; @@ -73,12 +74,14 @@ integration('runtime clock shift persistence', () => { await db.inputEvent.deleteMany({ where: { requestId } }); await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); + await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } }); }); afterAll(async () => { await db.inputEvent.deleteMany({ where: { requestId } }); await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); + await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } }); await closeDb?.(); }); @@ -90,6 +93,11 @@ integration('runtime clock shift persistence', () => { currentYear: 190, currentMonth: 1, tickSeconds: 600, + clockBaseTime: base, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: base, + lastTurnTick: 0, config: {}, meta: { lastTurnTime: base.toISOString(), @@ -110,6 +118,7 @@ integration('runtime clock shift persistence', () => { cityId: general.cityId, troopId: general.troopId, turnTime: general.turnTime, + turnTick: BigInt((general.id === generalIds[0] ? 1 : 2) * GAME_TICKS_PER_TURN), })), }); const auctionRows = await Promise.all( @@ -132,6 +141,11 @@ integration('runtime clock shift persistence', () => { currentMonth: 1, tickSeconds: 600, lastTurnTime: base, + clockBaseTime: base, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: base, + lastTurnTick: 0, meta: row.meta as Record, }; const snapshot: TurnWorldSnapshot = { @@ -229,13 +243,16 @@ integration('runtime clock shift persistence', () => { generalId: 0, }); expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z'); - expect((await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).meta).toMatchObject({ + const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } }); + expect(storedWorld.meta).toMatchObject({ lastTurnTime: '2099-07-30T09:45:00.000Z', starttime: '2099-06-30 23:45:00', }); - expect((await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } })).turnTime.toISOString()).toBe( - '2099-07-30T10:05:00.000Z' - ); + expect(storedWorld.clockTick).toBe(0n); + expect(storedWorld.lastTurnTick).toBe(0n); + const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } }); + expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z'); + expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN)); const storedAuctions = await db.auction.findMany({ where: { id: { in: auctionRows.map((auction) => auction.id) } }, }); diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index c095c7a..594e197 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -14,6 +14,173 @@ import { const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); describe('TurnDaemonLifecycle', () => { + it('runs manual game time to each monthly snapshot without waiting for wall time', async () => { + const wallNow = new Date('2026-01-01T00:00:00.000Z'); + const operationalClock = new ManualClock(wallNow.getTime()); + const queue = new InMemoryControlQueue(); + let lastTurnTime = new Date('2042-01-01T00:00:00.000Z'); + let gameNow = new Date(lastTurnTime); + const targets: string[] = []; + const lifecycle = new TurnDaemonLifecycle( + { + clock: operationalClock, + controlQueue: queue, + getNextTickTime: (value) => addMinutes(value, 60), + stateStore: { + loadLastTurnTime: async () => lastTurnTime, + loadNextGeneralTurnTime: async () => addMinutes(lastTurnTime, 30), + saveLastTurnTime: async (value) => { + lastTurnTime = value; + }, + loadCheckpoint: async () => undefined, + saveCheckpoint: async () => {}, + loadGameClock: async () => ({ mode: 'manual', now: gameNow }), + advanceGameClockTo: async (target) => { + gameNow = target; + }, + }, + processor: { + run: async (target): Promise => { + targets.push(target.toISOString()); + if (targets.length === 3) { + queue.enqueue({ type: 'shutdown', reason: 'verified' }); + } + return { + lastTurnTime: target.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }; + }, + }, + }, + { + profile: 'manual-clock', + defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 }, + } + ); + + await lifecycle.start(); + + expect(targets).toEqual(['2042-01-01T01:00:00.000Z', '2042-01-01T02:00:00.000Z', '2042-01-01T03:00:00.000Z']); + expect(operationalClock.nowMs()).toBe(wallNow.getTime()); + }); + + it('drains restart-overdue generals without advancing or catching up a month', async () => { + const gameNow = new Date('2042-01-01T03:00:00.000Z'); + const queue = new InMemoryControlQueue(); + const observedTargets: Date[] = []; + const lifecycle = new TurnDaemonLifecycle( + { + clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()), + controlQueue: queue, + getNextTickTime: (value) => addMinutes(value, 60), + stateStore: { + loadLastTurnTime: async () => gameNow, + loadNextGeneralTurnTime: async () => addMinutes(gameNow, -30), + saveLastTurnTime: async () => {}, + loadCheckpoint: async () => undefined, + saveCheckpoint: async () => {}, + loadGameClock: async () => ({ mode: 'manual', now: gameNow }), + advanceGameClockTo: async () => {}, + }, + processor: { + run: async (target): Promise => { + observedTargets.push(target); + queue.enqueue({ type: 'shutdown', reason: 'verified' }); + return { + lastTurnTime: gameNow.toISOString(), + processedGenerals: 1, + processedTurns: 0, + durationMs: 0, + partial: false, + }; + }, + }, + }, + { + profile: 'manual-overdue', + defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 }, + } + ); + + await lifecycle.start(); + + expect(observedTargets[0]?.toISOString()).toBe('2042-01-01T02:59:59.999Z'); + }); + + it('produces the same command, RNG, and resource state in realtime and manual modes', async () => { + const start = new Date('2042-01-01T00:00:00.000Z'); + const runMode = async (mode: 'realtime' | 'manual') => { + const operationalClock = new ManualClock( + mode === 'realtime' ? start.getTime() + 3 * 60 * 60_000 : start.getTime() + ); + const queue = new InMemoryControlQueue(); + let lastTurnTime = new Date(start); + let gameNow = new Date(start); + let rng = 17; + let resource = 100; + const commands: string[] = []; + const lifecycle = new TurnDaemonLifecycle( + { + clock: operationalClock, + controlQueue: queue, + getNextTickTime: (value) => addMinutes(value, 60), + stateStore: { + loadLastTurnTime: async () => lastTurnTime, + loadNextGeneralTurnTime: async () => null, + saveLastTurnTime: async (value) => { + lastTurnTime = value; + }, + loadCheckpoint: async () => undefined, + saveCheckpoint: async () => {}, + loadGameClock: async (wallNow) => ({ + mode, + now: + mode === 'manual' + ? gameNow + : new Date(start.getTime() + ((wallNow ?? start).getTime() - start.getTime())), + }), + advanceGameClockTo: async (target) => { + gameNow = target; + }, + }, + processor: { + run: async (target): Promise => { + while (lastTurnTime.getTime() < target.getTime()) { + lastTurnTime = addMinutes(lastTurnTime, 60); + rng = (rng * 48_271) % 2_147_483_647; + const command = rng % 2 === 0 ? 'develop' : 'train'; + commands.push(command); + resource += command === 'develop' ? 7 : -3; + } + if (commands.length >= 3) { + queue.enqueue({ type: 'shutdown', reason: `${mode} verified` }); + } + return { + lastTurnTime: lastTurnTime.toISOString(), + processedGenerals: commands.length, + processedTurns: commands.length, + durationMs: 0, + partial: false, + }; + }, + }, + }, + { + profile: `${mode}-equivalence`, + defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 10 }, + } + ); + + await lifecycle.start(); + return { commands, rng, resource, lastTurnTime: lastTurnTime.toISOString() }; + }; + + expect(await runMode('manual')).toEqual(await runMode('realtime')); + }); + it('restores engine state when a scheduled calculation throws', async () => { const now = new Date('2026-01-01T00:10:00.000Z'); const queue = new InMemoryControlQueue(); diff --git a/app/game-engine/test/turnOrder.test.ts b/app/game-engine/test/turnOrder.test.ts index 8dbd8b3..cbed91d 100644 --- a/app/game-engine/test/turnOrder.test.ts +++ b/app/game-engine/test/turnOrder.test.ts @@ -170,6 +170,13 @@ describe('InMemoryTurnProcessor ordering', () => { expect(world.getNextGeneralId()).toBe(4); expect(world.getNextGeneralId()).toBe(5); expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 }); + + const overdue = world.getGeneralById(1); + expect(overdue).toBeDefined(); + overdue!.turnTime = addMinutes(baseTime, 5); + const overdueResult = await processor.run(addMinutes(baseTime, 5), budget); + expect(overdueResult.processedGenerals).toBe(1); + expect(executed.at(-1)).toBe(1); }); it('stops catch-up immediately after a calendar handler finalizes unification', async () => { diff --git a/app/gateway-api/src/orchestrator/profileSeedCli.ts b/app/gateway-api/src/orchestrator/profileSeedCli.ts index e6db525..f1c0afc 100644 --- a/app/gateway-api/src/orchestrator/profileSeedCli.ts +++ b/app/gateway-api/src/orchestrator/profileSeedCli.ts @@ -55,6 +55,7 @@ export const runProfileSeedCli = async (env: NodeJS.ProcessEnv = process.env): P databaseUrl, scenarioId: request.scenarioId, tickSeconds: request.tickSeconds, + gameClockMode: process.env.GAME_CLOCK_MODE === 'manual' ? 'manual' : 'realtime', now: new Date(request.now), installOptions: request.installOptions ? { diff --git a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts index 252c51a..9a8b135 100644 --- a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts +++ b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts @@ -1,6 +1,6 @@ import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine'; import type { GamePrisma } from '@sammo-ts/infra'; -import { asRecord } from '@sammo-ts/common'; +import { GameClock, asRecord, type GameClockMode } from '@sammo-ts/common'; export interface AdminSeedUser { id: string; @@ -12,6 +12,7 @@ export interface SeedProfileDatabaseOptions { databaseUrl: string; scenarioId: number; tickSeconds?: number; + gameClockMode?: GameClockMode; now?: Date; installOptions?: ScenarioInstallOptions; scenarioOptions?: Parameters[0]['scenarioOptions']; @@ -105,6 +106,13 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse 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 gameClock = new GameClock({ + baseTime: worldState.clockBaseTime ?? turnTime, + tick: Number(worldState.clockTick ?? 0n), + mode: worldState.clockMode === 'manual' ? 'manual' : 'realtime', + wallAnchor: worldState.clockWallAnchor ?? turnTime, + turnSeconds: worldState.tickSeconds, + }); await prisma.general.create({ data: { @@ -122,6 +130,7 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse specialCode: 'None', special2Code: 'None', turnTime, + turnTick: BigInt(gameClock.dateToTick(turnTime)), meta: { createdBy: 'admin-seed', killturn: 24, @@ -137,6 +146,7 @@ export const seedProfileDatabase = async (options: SeedProfileDatabaseOptions) = scenarioId: options.scenarioId, databaseUrl: options.databaseUrl, tickSeconds: options.tickSeconds, + gameClockMode: options.gameClockMode, now: options.now, installOptions: options.installOptions, scenarioOptions: options.scenarioOptions, diff --git a/docs/architecture/game-clock.md b/docs/architecture/game-clock.md new file mode 100644 index 0000000..a7416f2 --- /dev/null +++ b/docs/architecture/game-clock.md @@ -0,0 +1,38 @@ +# 게임 시계 + +게임 진행 시각은 `world_state.clock_tick`이 기준입니다. 벽시계는 daemon lease, +요청 timeout, 처리 budget과 같은 운영 제어에만 사용합니다. 장수 턴, 메시지 +유효기간, 투표, 경매와 대회 마감은 game tick 또는 그 tick에서 투영한 시각을 +사용합니다. + +한 턴은 항상 `36,000,000` tick입니다. `tick_seconds`가 바뀌면 현재 표시 +시각이 유지되도록 `clock_base_time`을 다시 계산하므로, 기존 장수 턴 순서와 +남은 턴 수가 보존됩니다. DateTime 필드는 이전 데이터와 화면을 위한 투영값이며 +tick 필드가 존재하면 tick이 우선합니다. + +## 실행 모드 + +- `GAME_CLOCK_MODE=realtime`: `clock_wall_anchor` 이후의 실제 경과시간을 + game tick으로 환산합니다. 벽시계가 뒤로 보정되어도 game tick은 감소하지 + 않습니다. +- `GAME_CLOCK_MODE=manual`: 벽시계를 읽어 게임을 전진하지 않습니다. turn + daemon은 다음 월 tick을 즉시 관찰 시각으로 삼고 그보다 이른 장수 snapshot을 + 실행하므로 wall-clock mode와 명령/RNG 순서를 유지한 채 장기 시뮬레이션을 + 최대 속도로 진행할 수 있습니다. 재시작 때 남은 overdue 장수는 현재 game + time보다 이른 범위만 먼저 처리합니다. + +프로필 설치 시 선택한 모드는 DB에 저장됩니다. daemon의 환경변수는 로드한 +모드를 명시적으로 덮어쓸 때만 사용해 주세요. + +## 중단 후 재개 + +운영 중단 시간을 따라잡지 않으려면 Gateway의 일정 지연/가속 작업을 사용해 +표시 기준시각을 옮깁니다. 이 작업은 `clock_base_time`과 DateTime 투영값을 +같이 이동하고, game tick 및 장수 턴 tick은 바꾸지 않습니다. 동시에 +`clock_wall_anchor`를 작업 실행 시각으로 다시 고정하므로 장기간 중단 뒤에도 +누락된 기간의 턴을 몰아서 실행하지 않습니다. + +DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 migration +재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은 +무기한 호환값은 안전한 정수 범위를 넘을 수 있으므로 tick을 `NULL`로 두고 +DateTime fallback을 사용합니다. diff --git a/docs/index.md b/docs/index.md index a53475c..4ac3ea9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,8 @@ features: [시간과 턴](./user/time-and-turns.md)과 [커맨드 목록](./user/command-catalog.generated.md)을 확인해 주세요. Profile과 Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요. +게임 진행 시각과 운영 벽시계의 경계는 +[게임 시계](./architecture/game-clock.md)에 설명합니다. 세부 문서는 다음 책임으로 나뉩니다. diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 52770a7..5fd12ff 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,5 +1,6 @@ export * from './rng.js'; export * from './time/Clock.js'; +export * from './time/GameClock.js'; export * from './util/BytesLike.js'; export * from './util/convertBytesLikeToArrayBuffer.js'; export * from './util/convertBytesLikeToUint8Array.js'; diff --git a/packages/common/src/time/GameClock.ts b/packages/common/src/time/GameClock.ts new file mode 100644 index 0000000..4c566fe --- /dev/null +++ b/packages/common/src/time/GameClock.ts @@ -0,0 +1,125 @@ +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 interface GameClockState { + baseTime: Date; + tick: number; + mode: GameClockMode; + wallAnchor: Date; + turnSeconds: number; +} + +const requireSafeTick = (tick: number): number => { + if (!Number.isSafeInteger(tick)) { + throw new Error(`Game tick must be a safe integer: ${tick}`); + } + return tick; +}; + +const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => { + const wholeSeconds = Math.trunc(tick / ticksPerSecond); + const remainingTicks = tick - wholeSeconds * ticksPerSecond; + const milliseconds = wholeSeconds * 1_000 + Math.round((remainingTicks * 1_000) / ticksPerSecond); + if (!Number.isSafeInteger(milliseconds)) { + throw new Error(`Game tick offset is outside the safe millisecond range: ${milliseconds}`); + } + return milliseconds; +}; + +export class GameClock { + readonly baseTime: Date; + readonly tick: number; + readonly mode: GameClockMode; + readonly wallAnchor: Date; + readonly turnSeconds: number; + readonly ticksPerSecond: number; + + constructor(state: GameClockState) { + if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) { + throw new Error('turnSeconds must be a positive integer.'); + } + if (GAME_TICKS_PER_TURN % state.turnSeconds !== 0) { + throw new Error(`turnSeconds ${state.turnSeconds} cannot be represented as integer game ticks.`); + } + if (state.mode !== 'realtime' && state.mode !== 'manual') { + throw new Error(`Unknown game clock mode: ${String(state.mode)}`); + } + if (Number.isNaN(state.baseTime.getTime()) || Number.isNaN(state.wallAnchor.getTime())) { + throw new Error('Game clock anchors must be valid dates.'); + } + this.baseTime = new Date(state.baseTime.getTime()); + this.tick = requireSafeTick(state.tick); + this.mode = state.mode; + this.wallAnchor = new Date(state.wallAnchor.getTime()); + this.turnSeconds = state.turnSeconds; + this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds; + } + + static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date { + requireSafeTick(tick); + if (!Number.isInteger(turnSeconds) || turnSeconds <= 0 || GAME_TICKS_PER_TURN % turnSeconds !== 0) { + throw new Error(`turnSeconds ${turnSeconds} cannot be represented as integer game ticks.`); + } + const ticksPerSecond = GAME_TICKS_PER_TURN / turnSeconds; + const offsetMs = tickOffsetMilliseconds(tick, ticksPerSecond); + const baseMs = projectedTime.getTime() - offsetMs; + if (!Number.isSafeInteger(baseMs)) { + throw new Error(`Projected game clock base is outside the safe Date range: ${baseMs}`); + } + const baseTime = new Date(baseMs); + if (Number.isNaN(baseTime.getTime())) { + throw new Error(`Projected game clock base is invalid: ${baseMs}`); + } + return baseTime; + } + + nowTick(wallNow: Date): number { + if (this.mode === 'manual') { + return this.tick; + } + // A wall-clock correction must never rewind already-observed gameplay. + const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow); + return elapsedTicks <= 0 ? this.tick : this.addTicks(this.tick, elapsedTicks); + } + + now(wallNow: Date): Date { + return this.tickToDate(this.nowTick(wallNow)); + } + + dateToTick(date: Date): number { + return requireSafeTick(this.ticksBetween(this.baseTime, date)); + } + + tickToDate(tick: number): Date { + requireSafeTick(tick); + const milliseconds = tickOffsetMilliseconds(tick, this.ticksPerSecond); + const projected = this.baseTime.getTime() + milliseconds; + if (!Number.isSafeInteger(projected)) { + throw new Error(`Projected game time is outside the safe Date range: ${projected}`); + } + const result = new Date(projected); + if (Number.isNaN(result.getTime())) { + throw new Error(`Projected game time is invalid: ${projected}`); + } + return result; + } + + addTicks(tick: number, delta: number): number { + requireSafeTick(delta); + return requireSafeTick(tick + delta); + } + + private ticksBetween(from: Date, to: Date): number { + const milliseconds = to.getTime() - from.getTime(); + if (!Number.isSafeInteger(milliseconds)) { + throw new Error('Game clock date difference is outside the safe integer range.'); + } + const wholeSeconds = Math.trunc(milliseconds / 1_000); + const remainingMilliseconds = milliseconds - wholeSeconds * 1_000; + return requireSafeTick( + wholeSeconds * this.ticksPerSecond + Math.round((remainingMilliseconds * this.ticksPerSecond) / 1_000) + ); + } +} diff --git a/packages/common/test/gameClock.test.ts b/packages/common/test/gameClock.test.ts new file mode 100644 index 0000000..bb48757 --- /dev/null +++ b/packages/common/test/gameClock.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { GAME_TICKS_PER_TURN, GameClock, MAX_SAFE_GAME_TICK } from '../src/time/GameClock.js'; + +describe('GameClock', () => { + const baseTime = new Date('2042-01-01T00:00:00.000Z'); + + it('projects the fixed Ref turn tick and ignores wall time in manual mode', () => { + const clock = new GameClock({ + baseTime, + tick: GAME_TICKS_PER_TURN, + mode: 'manual', + wallAnchor: new Date('2026-01-01T00:00:00.000Z'), + turnSeconds: 3_600, + }); + + expect(clock.ticksPerSecond).toBe(10_000); + expect(clock.nowTick(new Date('2126-01-01T00:00:00.000Z'))).toBe(GAME_TICKS_PER_TURN); + expect(clock.now(new Date('2126-01-01T00:00:00.000Z')).toISOString()).toBe('2042-01-01T01:00:00.000Z'); + }); + + it('accepts a large forward wall jump without losing tick precision', () => { + const wallAnchor = new Date('2026-01-01T00:00:00.000Z'); + const clock = new GameClock({ + baseTime, + tick: 0, + mode: 'realtime', + wallAnchor, + turnSeconds: 7_200, + }); + const jumped = new Date('2126-01-01T00:00:00.000Z'); + const tick = clock.nowTick(jumped); + + expect(Number.isSafeInteger(tick)).toBe(true); + expect(clock.tickToDate(tick).getTime() - baseTime.getTime()).toBe(jumped.getTime() - wallAnchor.getTime()); + }); + + it('does not rewind game time after a backward wall-clock correction', () => { + const clock = new GameClock({ + baseTime, + tick: GAME_TICKS_PER_TURN * 10, + mode: 'realtime', + wallAnchor: new Date('2026-01-02T00:00:00.000Z'), + turnSeconds: 3_600, + }); + + expect(clock.nowTick(new Date('2025-01-01T00:00:00.000Z'))).toBe(GAME_TICKS_PER_TURN * 10); + }); + + it('projects near the safe tick boundary without unsafe intermediate multiplication', () => { + const clock = new GameClock({ + baseTime: new Date(0), + tick: 0, + mode: 'manual', + wallAnchor: new Date(0), + turnSeconds: 7_200, + }); + const tick = MAX_SAFE_GAME_TICK - (MAX_SAFE_GAME_TICK % clock.ticksPerSecond); + const projected = clock.tickToDate(tick); + + expect(Number.isNaN(projected.getTime())).toBe(false); + expect(clock.dateToTick(projected)).toBe(tick); + }); +}); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index d05fd8d..6cafa1b 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -94,14 +94,19 @@ 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") - 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") + config Json @default(dbgenerated("'{}'::jsonb")) + meta Json @default(dbgenerated("'{}'::jsonb")) + updatedAt DateTime @updatedAt @map("updated_at") trafficPeriods TrafficPeriod[] @@ -183,7 +188,9 @@ model General { horseCode String @default("None") @map("horse_code") itemCode String @default("None") @map("item_code") turnTime DateTime @map("turn_time") + turnTick BigInt? @map("turn_tick") recentWarTime DateTime? @map("recent_war_time") + recentWarTick BigInt? @map("recent_war_tick") age Int @default(20) startAge Int @default(20) @map("start_age") personalCode String @default("None") @map("personal_code") @@ -199,12 +206,13 @@ model General { } model SelectPoolEntry { - id Int @id @default(autoincrement()) - uniqueName String @unique @map("unique_name") @db.VarChar(20) - ownerUserId String? @map("owner_user_id") - generalId Int? @unique @map("general_id") - reservedUntil DateTime? @map("reserved_until") - info Json + id Int @id @default(autoincrement()) + uniqueName String @unique @map("unique_name") @db.VarChar(20) + ownerUserId String? @map("owner_user_id") + generalId Int? @unique @map("general_id") + reservedUntil DateTime? @map("reserved_until") + reservedUntilTick BigInt? @map("reserved_until_tick") + info Json @@index([ownerUserId]) @@index([reservedUntil, generalId]) @@ -212,13 +220,15 @@ model SelectPoolEntry { } model NpcSelectionToken { - ownerUserId String @id @map("owner_user_id") - validUntil DateTime @map("valid_until") - pickMoreFrom DateTime @map("pick_more_from") - pickResult Json @map("pick_result") - nonce Int - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + ownerUserId String @id @map("owner_user_id") + validUntil DateTime @map("valid_until") + validUntilTick BigInt? @map("valid_until_tick") + pickMoreFrom DateTime @map("pick_more_from") + pickMoreFromTick BigInt? @map("pick_more_from_tick") + pickResult Json @map("pick_result") + nonce Int + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@index([validUntil]) @@map("select_npc_token") @@ -280,14 +290,16 @@ model MessageReadState { } model Message { - id Int @id @default(autoincrement()) - mailbox Int - type String - src Int - dest Int - time DateTime - validUntil DateTime @map("valid_until") - message Json + 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 @@map("message") } @@ -674,6 +686,8 @@ model Auction { detail Json @default(dbgenerated("'{}'::jsonb")) status AuctionStatus @default(OPEN) closeAt DateTime @map("close_at") + openTick BigInt? @map("open_tick") + closeTick BigInt? @map("close_tick") latestEventId String @default("") @map("latest_event_id") latestEventAt DateTime @default(now()) @map("latest_event_at") finalizingAt DateTime? @map("finalizing_at") @@ -770,7 +784,9 @@ model VotePoll { openerGeneralId Int @map("opener_general_id") openerName String @map("opener_name") startAt DateTime @default(now()) @map("start_at") + startTick BigInt? @map("start_tick") endAt DateTime? @map("end_at") + endTick BigInt? @map("end_tick") closedAt DateTime? @map("closed_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/packages/infra/prisma/migrations/20260803000000_add_logical_game_clock/migration.sql b/packages/infra/prisma/migrations/20260803000000_add_logical_game_clock/migration.sql new file mode 100644 index 0000000..a925d60 --- /dev/null +++ b/packages/infra/prisma/migrations/20260803000000_add_logical_game_clock/migration.sql @@ -0,0 +1,60 @@ +ALTER TABLE world_state + ADD COLUMN clock_base_time TIMESTAMP(3), + ADD COLUMN clock_tick BIGINT, + ADD COLUMN clock_mode TEXT NOT NULL DEFAULT 'realtime', + ADD COLUMN clock_wall_anchor TIMESTAMP(3), + ADD COLUMN last_turn_tick BIGINT; + +UPDATE world_state +SET clock_base_time = COALESCE(NULLIF(meta->>'lastTurnTime', '')::timestamp, updated_at), + clock_wall_anchor = CURRENT_TIMESTAMP, + clock_tick = ROUND( + EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - COALESCE(NULLIF(meta->>'lastTurnTime', '')::timestamp, updated_at))) + * (36000000::numeric / tick_seconds) + )::bigint, + last_turn_tick = 0; + +ALTER TABLE general + ADD COLUMN turn_tick BIGINT, + ADD COLUMN recent_war_tick BIGINT; + +UPDATE general +SET turn_tick = ROUND( + EXTRACT(EPOCH FROM (general.turn_time - world_state.clock_base_time)) + * (36000000::numeric / world_state.tick_seconds) + )::bigint, + recent_war_tick = CASE WHEN general.recent_war_time IS NULL THEN NULL ELSE ROUND( + EXTRACT(EPOCH FROM (general.recent_war_time - world_state.clock_base_time)) + * (36000000::numeric / world_state.tick_seconds) + )::bigint END +FROM world_state; + +ALTER TABLE select_pool ADD COLUMN reserved_until_tick BIGINT; +ALTER TABLE select_npc_token ADD COLUMN valid_until_tick BIGINT, ADD COLUMN pick_more_from_tick BIGINT; +ALTER TABLE message ADD COLUMN time_tick BIGINT, ADD COLUMN valid_until_tick BIGINT; +ALTER TABLE auction ADD COLUMN open_tick BIGINT, ADD COLUMN close_tick BIGINT; +ALTER TABLE vote_poll ADD COLUMN start_tick BIGINT, ADD COLUMN end_tick BIGINT; + +UPDATE select_pool SET reserved_until_tick = CASE WHEN reserved_until IS NULL THEN NULL ELSE ROUND(EXTRACT(EPOCH FROM (reserved_until - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint END FROM world_state; +UPDATE select_npc_token SET valid_until_tick = ROUND(EXTRACT(EPOCH FROM (valid_until - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint, pick_more_from_tick = ROUND(EXTRACT(EPOCH FROM (pick_more_from - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint FROM world_state; +UPDATE message +SET time_tick = ROUND( + EXTRACT(EPOCH FROM (time - world_state.clock_base_time)) + * (36000000::numeric / world_state.tick_seconds) + )::bigint, + valid_until_tick = CASE + -- Legacy permanent messages use the year-9999 sentinel. Keep their + -- DateTime fallback instead of persisting a tick JavaScript cannot + -- represent safely. + WHEN valid_until >= TIMESTAMP '9999-01-01 00:00:00' THEN NULL + ELSE ROUND( + EXTRACT(EPOCH FROM (valid_until - world_state.clock_base_time)) + * (36000000::numeric / world_state.tick_seconds) + )::bigint + END +FROM world_state; +UPDATE auction SET open_tick = ROUND(EXTRACT(EPOCH FROM (created_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint, close_tick = ROUND(EXTRACT(EPOCH FROM (close_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint FROM world_state; +UPDATE vote_poll SET start_tick = ROUND(EXTRACT(EPOCH FROM (start_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint, end_tick = CASE WHEN end_at IS NULL THEN NULL ELSE ROUND(EXTRACT(EPOCH FROM (end_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint END FROM world_state; + +CREATE INDEX general_turn_tick_id_idx ON general(turn_tick, id); +CREATE INDEX auction_status_close_tick_idx ON auction(status, close_tick); diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index d8a0a17..f7859e6 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -9,6 +9,11 @@ export interface TurnEngineWorldStateRow { currentYear: number; currentMonth: number; tickSeconds: number; + clockBaseTime: Date | null; + clockTick: bigint | null; + clockMode: string; + clockWallAnchor: Date | null; + lastTurnTick: bigint | null; config: JsonValue; meta: JsonValue; updatedAt?: Date | null; @@ -53,7 +58,9 @@ export interface TurnEngineGeneralRow { meta: JsonValue; penalty: JsonValue; turnTime: Date; + turnTick: bigint | null; recentWarTime: Date | null; + recentWarTick: bigint | null; } export interface TurnEngineRankDataRow { @@ -157,6 +164,11 @@ export interface TurnEngineWorldStateUpdateInput { currentYear: number; currentMonth: number; tickSeconds: number; + clockBaseTime: Date; + clockTick: bigint; + clockMode: string; + clockWallAnchor: Date; + lastTurnTick: bigint; meta: InputJsonValue; } @@ -165,6 +177,11 @@ export interface TurnEngineWorldStateCreateInput { currentYear: number; currentMonth: number; tickSeconds: number; + clockBaseTime: Date; + clockTick: bigint; + clockMode: string; + clockWallAnchor: Date; + lastTurnTick: bigint; config: InputJsonValue; meta: InputJsonValue; } @@ -207,7 +224,9 @@ export interface TurnEngineGeneralUpdateInput { penalty: InputJsonValue; meta: InputJsonValue; turnTime: Date; + turnTick: bigint; recentWarTime: Date | null; + recentWarTick: bigint | null; } export interface TurnEngineGeneralCreateManyInput { @@ -241,7 +260,9 @@ export interface TurnEngineGeneralCreateManyInput { special2Code: string; meta: InputJsonValue; turnTime: Date; + turnTick: bigint; recentWarTime?: Date | null; + recentWarTick?: bigint | null; affinity?: number | null; bornYear?: number; deadYear?: number;