diff --git a/app/game-api/src/auction/worker.ts b/app/game-api/src/auction/worker.ts index f2b4191..4eaa8d7 100644 --- a/app/game-api/src/auction/worker.ts +++ b/app/game-api/src/auction/worker.ts @@ -2,6 +2,7 @@ import { createGamePostgresConnector, createRedisConnector, GamePrisma, + type GamePrismaClient, resolvePostgresConfigFromEnv, resolveRedisConfigFromEnv, } from '@sammo-ts/infra'; @@ -48,6 +49,50 @@ const getNextDueMs = async (redis: RedisTimerClient, timerKey: string): Promise< return next[0]?.score ?? null; }; +export const processDueAuctionId = async (options: { + db: GamePrismaClient; + redis: RedisTimerClient; + timerKey: string; + historyKey: string; + id: string; + nowMs: number; + sendCommand: (command: { type: 'auctionFinalize'; auctionId: number }) => Promise; +}): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => { + const { db, redis, timerKey, historyKey, id, nowMs, sendCommand } = options; + const auctionId = Number(id); + if (!Number.isFinite(auctionId)) { + return 'IGNORED'; + } + const now = new Date(nowMs); + const updated = await db.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET status = 'FINALIZING', + finalizing_at = ${now}, + updated_at = ${now} + WHERE id = ${auctionId} + AND status = 'OPEN' + AND close_at <= ${now} + ` + ); + + if (updated > 0) { + await redis.zAdd(historyKey, [{ score: nowMs, value: id }]); + await sendCommand({ type: 'auctionFinalize', auctionId }); + return 'FINALIZING'; + } + + const current = await db.auction.findFirst({ + where: { id: auctionId, status: 'OPEN' }, + select: { closeAt: true }, + }); + if (!current) { + return 'IGNORED'; + } + await redis.zAdd(timerKey, [{ score: current.closeAt.getTime(), value: String(auctionId) }]); + return 'RESCHEDULED'; +}; + export const runAuctionWorker = async (): Promise => { const config = resolveGameApiConfigFromEnv(); const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile })); @@ -84,32 +129,16 @@ export const runAuctionWorker = async (): Promise => { const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, nowMs, 100); if (dueIds.length > 0) { - const now = new Date(nowMs); - await redis.client.zAdd( - keys.historyKey, - dueIds.map((id) => ({ score: nowMs, value: id })) - ); for (const id of dueIds) { - const auctionId = Number(id); - if (!Number.isFinite(auctionId)) { - continue; - } - - const updated = await postgres.prisma.$executeRaw( - GamePrisma.sql` - UPDATE auction - SET status = 'FINALIZING', - finalizing_at = ${now}, - updated_at = ${now} - WHERE id = ${auctionId} - AND status = 'OPEN' - AND close_at <= ${now} - ` - ); - - if (updated > 0) { - await daemonTransport.sendCommand({ type: 'auctionFinalize', auctionId }); - } + await processDueAuctionId({ + db: postgres.prisma, + redis: redis.client, + timerKey: keys.timerKey, + historyKey: keys.historyKey, + id, + nowMs, + sendCommand: (command) => daemonTransport.sendCommand(command), + }); } continue; } diff --git a/app/game-api/test/auctionWorker.test.ts b/app/game-api/test/auctionWorker.test.ts new file mode 100644 index 0000000..713676d --- /dev/null +++ b/app/game-api/test/auctionWorker.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrismaClient } from '@sammo-ts/infra'; + +import { processDueAuctionId } from '../src/auction/worker.js'; + +const buildRedis = () => ({ + zRangeByScore: vi.fn(async () => []), + zRangeWithScores: vi.fn(async () => []), + zAdd: vi.fn(async () => 1), + zRem: vi.fn(async () => 0), + zRemRangeByScore: vi.fn(async () => 0), +}); + +describe('auction worker clock-shift race', () => { + it('requeues an OPEN auction at its current DB deadline when an old due score loses the race', async () => { + const redis = buildRedis(); + const closeAt = new Date('2026-07-30T12:15:00.000Z'); + const db = { + $executeRaw: vi.fn(async () => 0), + auction: { + findFirst: vi.fn(async () => ({ closeAt })), + }, + } as unknown as GamePrismaClient; + const sendCommand = vi.fn(async () => {}); + + await expect( + processDueAuctionId({ + db, + redis, + timerKey: 'timer', + historyKey: 'history', + id: '7', + nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), + sendCommand, + }) + ).resolves.toBe('RESCHEDULED'); + + expect(redis.zAdd).toHaveBeenCalledTimes(1); + expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]); + expect(sendCommand).not.toHaveBeenCalled(); + }); + + it('records history and finalizes only after the guarded DB transition succeeds', async () => { + const redis = buildRedis(); + const db = { + $executeRaw: vi.fn(async () => 1), + auction: { + findFirst: vi.fn(), + }, + } as unknown as GamePrismaClient; + const sendCommand = vi.fn(async () => {}); + const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime(); + + await expect( + processDueAuctionId({ + db, + redis, + timerKey: 'timer', + historyKey: 'history', + id: '7', + nowMs, + sendCommand, + }) + ).resolves.toBe('FINALIZING'); + + expect(redis.zAdd).toHaveBeenCalledWith('history', [{ score: nowMs, value: '7' }]); + expect(db.auction.findFirst).not.toHaveBeenCalled(); + expect(sendCommand).toHaveBeenCalledWith({ type: 'auctionFinalize', auctionId: 7 }); + }); +}); diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 13ed93e..19c095d 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -276,30 +276,36 @@ export class TurnDaemonLifecycle { const executeHandler = async ( context?: TurnDaemonCommandExecutionContext ): Promise => { - const execute = async (): Promise => { - const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null; - return ( - handled ?? { - type: 'commandRejected', - ok: false, - commandType: command.type, - reason: '턴 데몬이 명령을 처리할 수 없습니다.', - } - ); - }; - return this.stateManager ? this.stateManager.transaction(execute) : execute(); + const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null; + return ( + handled ?? { + type: 'commandRejected', + ok: false, + commandType: command.type, + reason: '턴 데몬이 명령을 처리할 수 없습니다.', + } + ); }; try { - if (command.requestId && this.hooks?.executeCommand) { - result = await this.hooks.executeCommand(command.requestId, executeHandler); - committedByExecutionBoundary = true; - } else { - result = await executeHandler(); - } + const executeAndCommit = async (): Promise => { + let nextResult: TurnDaemonCommandResult; + if (command.requestId && this.hooks?.executeCommand) { + nextResult = await this.hooks.executeCommand(command.requestId, executeHandler); + committedByExecutionBoundary = true; + } else { + nextResult = await executeHandler(); + } + if (!committedByExecutionBoundary && command.requestId && this.hooks?.commitCommand) { + await this.hooks.commitCommand(command.requestId, nextResult); + } + return nextResult; + }; + result = this.stateManager + ? await this.stateManager.transaction(executeAndCommit) + : await executeAndCommit(); } catch (error) { - // A handler may already have changed the in-memory world. Do not commit - // either those changes or the inbox completion marker after an exception. - // Pausing forces a reload/retry instead of acknowledging a partial event. + // The state-manager boundary includes the durable command commit, so a + // database/fencing failure restores every in-memory mutation as well. this.status.state = 'paused'; this.status.paused = true; this.errorPaused = true; @@ -308,17 +314,11 @@ export class TurnDaemonLifecycle { return; } - if (!committedByExecutionBoundary && command.requestId && this.hooks?.commitCommand) { - try { - await this.hooks.commitCommand(command.requestId, result); - } catch (error) { - this.status.state = 'paused'; - this.status.paused = true; - this.errorPaused = true; - this.status.lastError = error instanceof Error ? error.message : 'Unknown input event commit error.'; - await this.hooks.onRunError?.(error); - return; - } + if (result.type === 'shiftSchedule' && result.ok) { + this.status.lastTurnTime = result.lastTurnTime; + this.status.checkpoint = result.checkpoint; + await this.stateStore.saveCheckpoint(result.checkpoint); + await this.resolveNextRunTime(); } if (this.commandResponder && command.requestId) { diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 3bfea14..d2c23d4 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -270,6 +270,12 @@ const zShutdown = z.object({ reason: z.string().optional(), }); +const zShiftSchedule = z.object({ + type: z.literal('shiftSchedule'), + actionId: z.string().uuid(), + deltaMinutes: z.number().int().min(-1440).max(1440).refine((value) => value !== 0), +}); + const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope) => { const command = parseWith(zAuctionFinalize, envelope.command); if (!command) { @@ -506,6 +512,14 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => { return command ? { ...command, requestId: envelope.requestId } : null; }; +const normalizeShiftSchedule: CommandNormalizer<'shiftSchedule'> = (envelope) => { + const command = parseWith(zShiftSchedule, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizers: CommandNormalizerMap = { auctionFinalize: normalizeAuctionFinalize, auctionOpen: normalizeAuctionOpen, @@ -538,6 +552,7 @@ const normalizers: CommandNormalizerMap = { pause: normalizePause, resume: normalizeResume, shutdown: normalizeShutdown, + shiftSchedule: normalizeShiftSchedule, }; export const normalizeTurnDaemonCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonCommand | null => { diff --git a/app/game-engine/src/turn/gatewayAdminActions.ts b/app/game-engine/src/turn/gatewayAdminActions.ts index 32e206a..068eeaa 100644 --- a/app/game-engine/src/turn/gatewayAdminActions.ts +++ b/app/game-engine/src/turn/gatewayAdminActions.ts @@ -1,9 +1,11 @@ import { createGatewayPostgresConnector } from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; -export type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED'; +export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED'; export interface GatewayAdminActionRecord { + id?: string; + profileName?: string; action?: string; requestedAt?: string; durationMinutes?: number | null; @@ -79,12 +81,71 @@ export const createGatewayAdminActionConsumer = async ( let timer: NodeJS.Timeout | null = null; let inFlight = false; + const pollRuntimeActions = async (): Promise => { + const pending = await prisma.gatewayRuntimeAction.findMany({ + where: { + profileName: options.profileName, + status: { in: ['REQUESTED', 'PARTIAL'] }, + OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: new Date() } }], + }, + orderBy: { createdAt: 'asc' }, + }); + for (const action of pending) { + const actionRecord: GatewayAdminActionRecord = { + id: action.id, + profileName: action.profileName, + action: action.action, + requestedAt: action.createdAt.toISOString(), + durationMinutes: action.durationMinutes, + scheduledAt: action.scheduledAt?.toISOString() ?? null, + reason: action.reason, + status: action.status, + handledAt: action.handledAt?.toISOString() ?? null, + handler: action.handler, + detail: action.detail, + }; + let result: GatewayAdminActionResult; + try { + result = await options.handler(actionRecord); + } catch (error) { + result = { + status: 'PARTIAL', + detail: error instanceof Error ? error.message : String(error), + }; + } + if (result.status === 'REQUESTED') { + continue; + } + const terminal = result.status !== 'PARTIAL'; + const updated = await prisma.gatewayRuntimeAction.updateMany({ + where: { + id: action.id, + status: { in: ['REQUESTED', 'PARTIAL'] }, + }, + data: { + status: result.status, + detail: result.detail ?? null, + handler: 'turn-daemon', + handledAt: terminal ? new Date() : null, + attempts: { increment: 1 }, + nextAttemptAt: terminal + ? null + : new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))), + }, + }); + if (terminal && updated.count > 0) { + await options.onActionApplied?.(actionRecord, result); + } + } + }; + const pollOnce = async (): Promise => { if (inFlight) { return; } inFlight = true; try { + await pollRuntimeActions(); const profile = await prisma.gatewayProfile.findUnique({ where: { profileName: options.profileName }, }); diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index dd5b088..3e544e9 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -782,6 +782,78 @@ export class InMemoryTurnWorld { }; } + shiftSchedule(deltaMinutes: number): { shiftedGenerals: number; lastTurnTime: string } { + if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) { + throw new Error('Schedule shift must be a non-zero integer number of minutes.'); + } + const deltaMs = deltaMinutes * 60_000; + const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs); + const shiftMetaDate = (value: unknown): unknown => { + if (typeof value !== 'string' || !value.trim()) { + return value; + } + if (value.includes('T')) { + const shifted = shiftDate(new Date(value)); + return Number.isNaN(shifted.getTime()) ? value : shifted.toISOString(); + } + const match = /^(\d{4})-(\d{2})-(\d{2})[ ](\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?$/.exec(value); + if (!match) { + return value; + } + const parts = match.slice(1).map(Number); + const shifted = new Date( + Date.UTC(parts[0]!, parts[1]! - 1, parts[2]!, parts[3]!, parts[4]!, parts[5]!) + deltaMs + ); + return ( + [ + shifted.getUTCFullYear().toString().padStart(4, '0'), + (shifted.getUTCMonth() + 1).toString().padStart(2, '0'), + shifted.getUTCDate().toString().padStart(2, '0'), + ].join('-') + + ' ' + + [ + shifted.getUTCHours().toString().padStart(2, '0'), + shifted.getUTCMinutes().toString().padStart(2, '0'), + shifted.getUTCSeconds().toString().padStart(2, '0'), + ].join(':') + + (match[7] ?? '') + ); + }; + + const nextLastTurnTime = shiftDate(this.state.lastTurnTime); + const nextMeta = { + ...this.state.meta, + lastTurnTime: nextLastTurnTime.toISOString(), + turntime: shiftMetaDate(this.state.meta.turntime), + starttime: shiftMetaDate(this.state.meta.starttime), + tnmt_time: shiftMetaDate(this.state.meta.tnmt_time), + }; + this.state = { + ...this.state, + lastTurnTime: nextLastTurnTime, + meta: nextMeta, + }; + + for (const general of this.generals.values()) { + this.updateGeneral(general.id, { turnTime: shiftDate(general.turnTime) }); + } + for (const auction of this.pendingNeutralAuctions) { + auction.closeAt = shiftDate(auction.closeAt); + } + if (this.checkpoint) { + const checkpointTime = shiftDate(new Date(this.checkpoint.turnTime)); + this.checkpoint = { + ...this.checkpoint, + turnTime: checkpointTime.toISOString(), + }; + } + + return { + shiftedGenerals: this.generals.size, + lastTurnTime: nextLastTurnTime.toISOString(), + }; + } + getNextNationId(): number { const meta = this.state.meta as Record; let lastId = (meta.lastNationId as number | undefined) ?? 0; diff --git a/app/game-engine/src/turn/runtimeClockShift.ts b/app/game-engine/src/turn/runtimeClockShift.ts new file mode 100644 index 0000000..97f946a --- /dev/null +++ b/app/game-engine/src/turn/runtimeClockShift.ts @@ -0,0 +1,211 @@ +import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; +import { randomUUID } from 'node:crypto'; + +import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common'; + +import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js'; + +interface RuntimeRedisClient { + get(key: string): Promise; + set( + key: string, + value: string, + options?: { + NX?: boolean; + PX?: number; + } + ): Promise; + del(key: string): Promise; + zAdd(key: string, values: Array<{ score: number; value: string }>): Promise; +} + +type TournamentClockState = { + nextAt?: string; + bettingCloseAt?: string; + runtimeClockShiftActionIds?: string[]; + [key: string]: unknown; +}; + +const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const isUniqueConflict = (error: unknown): boolean => + typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; + +const shiftDateText = (value: unknown, deltaMinutes: number): unknown => { + if (typeof value !== 'string' || !value.trim()) { + return value; + } + const shifted = new Date(new Date(value).getTime() + deltaMinutes * 60_000); + return Number.isNaN(shifted.getTime()) ? value : shifted.toISOString(); +}; + +const syncAuctionTimers = async ( + db: GamePrismaClient, + redis: RuntimeRedisClient, + profileName: string +): Promise => { + const auctions = await db.auction.findMany({ + where: { status: 'OPEN' }, + select: { id: true, closeAt: true }, + }); + if (auctions.length > 0) { + await redis.zAdd( + `sammo:${profileName}:auction:timer`, + auctions.map((auction) => ({ + score: auction.closeAt.getTime(), + value: String(auction.id), + })) + ); + } + return auctions.length; +}; + +const shiftTournamentClock = async ( + redis: RuntimeRedisClient, + profileName: string, + actionId: string, + deltaMinutes: number +): Promise => { + const stateKey = `sammo:${profileName}:tournament:state`; + const lockKey = `${stateKey}:mutation-lock`; + const token = randomUUID(); + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 }); + if (acquired) { + try { + const rawState = await redis.get(stateKey); + if (!rawState) { + return false; + } + const state = JSON.parse(rawState) as TournamentClockState; + const applied = Array.isArray(state.runtimeClockShiftActionIds) + ? state.runtimeClockShiftActionIds.filter((entry): entry is string => typeof entry === 'string') + : []; + if (applied.includes(actionId)) { + return true; + } + const nextState: TournamentClockState = { + ...state, + nextAt: shiftDateText(state.nextAt, deltaMinutes) as string | undefined, + bettingCloseAt: shiftDateText(state.bettingCloseAt, deltaMinutes) as string | undefined, + runtimeClockShiftActionIds: [...applied, actionId], + }; + await redis.set(stateKey, JSON.stringify(nextState)); + return true; + } finally { + if ((await redis.get(lockKey)) === token) { + await redis.del(lockKey); + } + } + } + await sleep(10); + } + throw new Error('토너먼트 시간 조정 lock을 획득하지 못했습니다.'); +}; + +const ensureEngineCommand = async ( + db: GamePrismaClient, + actionId: string, + deltaMinutes: number +): Promise<{ requestId: string; result?: TurnDaemonCommandResult; failed?: string }> => { + const requestId = `gateway-runtime:${actionId}`; + const command: TurnDaemonCommand = { + type: 'shiftSchedule', + requestId, + actionId, + deltaMinutes, + }; + try { + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: command.type, + payload: asJson(command), + }, + }); + } catch (error) { + if (!isUniqueConflict(error)) { + throw error; + } + const existing = await db.inputEvent.findUniqueOrThrow({ + where: { requestId }, + select: { eventType: true, payload: true }, + }); + const payload = existing.payload as Partial; + if ( + existing.eventType !== command.type || + payload.type !== command.type || + payload.actionId !== actionId || + payload.deltaMinutes !== deltaMinutes + ) { + return { requestId, failed: '같은 action ID에 다른 시간 조정 payload가 이미 존재합니다.' }; + } + } + + const event = await db.inputEvent.findUniqueOrThrow({ + where: { requestId }, + select: { status: true, result: true, error: true }, + }); + if (event.status === 'FAILED') { + return { requestId, failed: event.error ?? '게임 엔진 시간 조정이 실패했습니다.' }; + } + if (event.status !== 'SUCCEEDED') { + return { requestId }; + } + return { requestId, result: event.result as TurnDaemonCommandResult }; +}; + +export const applyRuntimeClockShift = async (options: { + action: GatewayAdminActionRecord; + profileName: string; + db: GamePrismaClient; + redis?: RuntimeRedisClient; +}): Promise => { + const { action, profileName, db, redis } = options; + if (!action.id) { + return { status: 'FAILED', detail: '시간 조정 action ID가 없습니다.' }; + } + if (!Number.isInteger(action.durationMinutes) || (action.durationMinutes ?? 0) < 1) { + return { status: 'FAILED', detail: '시간 조정 분은 1 이상의 정수여야 합니다.' }; + } + const direction = action.action === 'ACCELERATE' ? -1 : action.action === 'DELAY' ? 1 : 0; + if (direction === 0) { + return { status: 'IGNORED', detail: `지원하지 않는 시간 조정 action입니다: ${action.action ?? ''}` }; + } + const deltaMinutes = direction * action.durationMinutes!; + const engine = await ensureEngineCommand(db, action.id, deltaMinutes); + if (engine.failed) { + return { status: 'FAILED', detail: engine.failed }; + } + if (!engine.result) { + return { status: 'REQUESTED', detail: `게임 엔진 처리 대기 중: ${engine.requestId}` }; + } + if (engine.result.type !== 'shiftSchedule' || !engine.result.ok) { + return { + status: 'FAILED', + detail: + engine.result.type === 'shiftSchedule' ? engine.result.reason : '게임 엔진이 다른 결과를 반환했습니다.', + }; + } + if (!redis) { + return { + status: 'PARTIAL', + detail: `DB 시간 조정은 적용됐지만 Redis timer 동기화를 기다리는 중입니다: ${engine.requestId}`, + }; + } + + const syncedAuctions = await syncAuctionTimers(db, redis, profileName); + const shiftedTournament = await shiftTournamentClock(redis, profileName, action.id, deltaMinutes); + return { + status: 'APPLIED', + detail: [ + `${Math.abs(deltaMinutes)}분 ${deltaMinutes < 0 ? '가속' : '연기'}`, + `장수 ${engine.result.shiftedGenerals}명`, + `경매 ${engine.result.shiftedAuctions}건(DB)/${syncedAuctions}건(timer)`, + shiftedTournament ? '토너먼트 적용' : '활성 토너먼트 없음', + ].join(' · '), + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 49625a3..2bddd65 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -76,6 +76,7 @@ import { import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; import { EngineStateManager } from './engineStateManager.js'; +import { applyRuntimeClockShift } from './runtimeClockShift.js'; export interface TurnDaemonRuntimeOptions { profile: string; @@ -755,10 +756,24 @@ const createTurnDaemonRuntimeWithLease = async ( pollIntervalMs: options.adminActionIntervalMs, handler: async (action) => { const reason = action.reason ?? `admin:${action.action ?? 'action'}`; + if (turnDaemonLease?.isLost()) { + return { status: 'REQUESTED', detail: 'turn-daemon lease 재획득을 기다리는 중입니다.' }; + } if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') { // 리셋은 오케스트레이터에서 빌드+재기동으로 처리한다. return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' }; } + if (action.action === 'ACCELERATE' || action.action === 'DELAY') { + if (!commandConnector) { + return { status: 'FAILED', detail: '게임 command database 연결이 없습니다.' }; + } + return applyRuntimeClockShift({ + action, + profileName: options.profileName!, + db: commandConnector.prisma, + redis: redisConnector?.client, + }); + } switch (action.action) { case 'RESUME': resolvedControlQueue.enqueue({ type: 'resume', reason }); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 0410cfb..7234256 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -4,7 +4,7 @@ import type { TurnDaemonCommandExecutionContext, TurnDaemonCommandResult, } from '../lifecycle/types.js'; -import type { GamePrisma } from '@sammo-ts/infra'; +import { GamePrisma } from '@sammo-ts/infra'; import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { LogCategory, @@ -442,6 +442,41 @@ async function handlePatchGeneral( return { type: 'patchGeneral', ok: true, generalId: command.generalId }; } +async function handleShiftSchedule( + ctx: CommandHandlerContext, + command: Extract +): Promise { + if (!ctx.commandDb) { + return { + type: 'shiftSchedule', + ok: false, + actionId: command.actionId, + reason: '시간 조정은 데이터베이스 transaction 경계에서만 실행할 수 있습니다.', + }; + } + + const shifted = ctx.world.shiftSchedule(command.deltaMinutes); + const shiftedAuctions = await ctx.commandDb.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET close_at = close_at + (${command.deltaMinutes} * INTERVAL '1 minute'), + updated_at = NOW() + WHERE status = 'OPEN' + ` + ); + + return { + type: 'shiftSchedule', + ok: true, + actionId: command.actionId, + deltaMinutes: command.deltaMinutes, + lastTurnTime: shifted.lastTurnTime, + shiftedGenerals: shifted.shiftedGenerals, + shiftedAuctions, + checkpoint: ctx.world.getCheckpoint(), + }; +} + async function handleTroopJoin( ctx: CommandHandlerContext, command: Extract @@ -1812,6 +1847,8 @@ export const createTurnDaemonCommandHandler = (options: { handleTournamentMatchResult(ctx, command as Extract), patchGeneral: (command) => handlePatchGeneral(ctx, command as Extract), + shiftSchedule: (command) => + handleShiftSchedule(ctx, command as Extract), }; return { diff --git a/app/game-engine/test/gatewayRuntimeAction.integration.test.ts b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts new file mode 100644 index 0000000..1429e68 --- /dev/null +++ b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts @@ -0,0 +1,97 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra'; + +import { createGatewayAdminActionConsumer } from '../src/turn/gatewayAdminActions.js'; + +const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const profileName = 'runtime:consumer-integration'; +const actionId = '924f40ec-e9d2-432f-9867-e9fb3199f14a'; + +const waitForApplied = async (db: GatewayPrismaClient): Promise => { + const deadline = Date.now() + 4_000; + while (Date.now() < deadline) { + const action = await db.gatewayRuntimeAction.findUnique({ + where: { id: actionId }, + select: { status: true }, + }); + if (action?.status === 'APPLIED') { + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error('gateway runtime action did not reach APPLIED'); +}; + +integration('gateway runtime action consumer', () => { + let db: GatewayPrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGatewayPostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.gatewayProfile.upsert({ + where: { profileName }, + update: { status: 'RUNNING' }, + create: { + profileName, + profile: 'runtime', + scenario: 'consumer-integration', + apiPort: 15998, + status: 'RUNNING', + }, + }); + await db.gatewayRuntimeAction.deleteMany({ where: { profileName } }); + }); + + afterAll(async () => { + await db.gatewayRuntimeAction.deleteMany({ where: { profileName } }); + await db.gatewayProfile.deleteMany({ where: { profileName } }); + await closeDb?.(); + }); + + it('backs off a partial projection and publishes one terminal callback', async () => { + await db.gatewayRuntimeAction.create({ + data: { + id: actionId, + profileName, + action: 'ACCELERATE', + durationMinutes: 15, + requestedBy: 'integration-admin', + }, + }); + const handler = vi + .fn() + .mockResolvedValueOnce({ status: 'PARTIAL', detail: 'redis unavailable' }) + .mockResolvedValue({ status: 'APPLIED', detail: 'projection complete' }); + const onActionApplied = vi.fn(async () => {}); + const consumer = await createGatewayAdminActionConsumer({ + databaseUrl: databaseUrl!, + gatewayDatabaseUrl: databaseUrl!, + profileName, + pollIntervalMs: 10, + handler, + onActionApplied, + }); + + consumer.start(); + try { + await waitForApplied(db); + } finally { + await consumer.stop(); + } + + expect(await db.gatewayRuntimeAction.findUniqueOrThrow({ where: { id: actionId } })).toMatchObject({ + status: 'APPLIED', + attempts: 2, + nextAttemptAt: null, + detail: 'projection complete', + handler: 'turn-daemon', + }); + expect(handler).toHaveBeenCalledTimes(2); + expect(onActionApplied).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/game-engine/test/inputEventAtomicity.test.ts b/app/game-engine/test/inputEventAtomicity.test.ts index bd14a3e..89a5288 100644 --- a/app/game-engine/test/inputEventAtomicity.test.ts +++ b/app/game-engine/test/inputEventAtomicity.test.ts @@ -305,8 +305,8 @@ describe('input event atomicity', () => { lastError: 'injected commit failure', }); expect(publishCommandResult).not.toHaveBeenCalled(); - expect(engineState).toEqual({ value: 'calculated' }); - expect(stateManager.getRevision()).toBe(1); + expect(engineState).toEqual({ value: 'before' }); + expect(stateManager.getRevision()).toBe(0); await lifecycle.stop('done'); await loop; diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts new file mode 100644 index 0000000..edc51d6 --- /dev/null +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrismaClient } from '@sammo-ts/infra'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildGeneral = (id: number, turnTime: string): TurnGeneral => + ({ + id, + name: `General_${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date(turnTime), + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + officerLevel: 5, + experience: 0, + dedication: 0, + injury: 0, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + }) as TurnGeneral; + +const buildWorld = (): InMemoryTurnWorld => { + const state: TurnWorldState = { + id: 1, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-30T10:00:00.000Z'), + meta: { + lastTurnTime: '2026-07-30T10:00:00.000Z', + turntime: '2026-07-30 10:00:00.123456', + starttime: '2026-07-01 00:00:00', + tnmt_time: '2026-07-30 11:30:00', + untouched: 'keep', + }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [buildGeneral(1, '2026-07-30T10:10:00.000Z'), buildGeneral(2, '2026-07-30T10:20:00.000Z')], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + }; + return new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); +}; + +describe('runtime clock shift', () => { + it.each([ + ['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'], + ['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'], + ] as const)('%s the world, every general, checkpoint, and pending auction together', (_, delta, last, general) => { + const world = buildWorld(); + world.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 }); + world.queueNeutralAuction({ + registrationKey: 'test', + type: 'BUY_RICE', + targetCode: 'rice', + hostGeneralId: 0, + hostName: '상인', + detail: {}, + closeAt: new Date('2026-07-30T12:00:00.000Z'), + }); + + const result = world.shiftSchedule(delta); + + expect(result).toEqual({ shiftedGenerals: 2, lastTurnTime: last }); + expect(world.getState()).toMatchObject({ + currentYear: 190, + currentMonth: 1, + lastTurnTime: new Date(last), + meta: { + lastTurnTime: last, + untouched: 'keep', + }, + }); + expect(world.getGeneralById(1)?.turnTime.toISOString()).toBe(general); + expect(world.getCheckpoint()?.turnTime).toBe(general); + const pendingCloseAt = world.peekDirtyState().pendingNeutralAuctions[0]?.closeAt; + expect(pendingCloseAt?.toISOString()).toBe( + new Date(new Date('2026-07-30T12:00:00.000Z').getTime() + delta * 60_000).toISOString() + ); + expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([1, 2]); + }); + + it.each([0, 1.5, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => { + const world = buildWorld(); + expect(() => world.shiftSchedule(delta)).toThrow(); + expect(world.getState().lastTurnTime.toISOString()).toBe('2026-07-30T10:00:00.000Z'); + expect(world.peekDirtyState().generals).toEqual([]); + }); + + it('keeps legacy wall-clock metadata independent from the process timezone', () => { + const world = buildWorld(); + + world.shiftSchedule(-15); + + expect(world.getState().meta).toMatchObject({ + turntime: '2026-07-30 09:45:00.123456', + starttime: '2026-06-30 23:45:00', + tnmt_time: '2026-07-30 11:15:00', + }); + }); +}); + +describe('runtime clock shift projection', () => { + it('waits for the durable engine event and applies Redis projections idempotently', async () => { + const actionId = '68f1f0e4-3b95-4aeb-9925-c7e93caf1ba7'; + let eventStatus: 'PENDING' | 'SUCCEEDED' = 'PENDING'; + let created = false; + const inputEventCreate = vi.fn(async () => { + if (created) { + throw { code: 'P2002' }; + } + created = true; + return {}; + }); + const db = { + inputEvent: { + create: inputEventCreate, + findUniqueOrThrow: vi.fn(async () => + eventStatus === 'PENDING' + ? { + eventType: 'shiftSchedule', + payload: { + type: 'shiftSchedule', + actionId, + deltaMinutes: -15, + }, + status: 'PENDING', + result: null, + error: null, + } + : { + eventType: 'shiftSchedule', + payload: { + type: 'shiftSchedule', + actionId, + deltaMinutes: -15, + }, + status: 'SUCCEEDED', + result: { + type: 'shiftSchedule', + ok: true, + actionId, + deltaMinutes: -15, + lastTurnTime: '2026-07-30T09:45:00.000Z', + shiftedGenerals: 2, + shiftedAuctions: 1, + }, + error: null, + } + ), + }, + auction: { + findMany: vi.fn(async () => [{ id: 7, closeAt: new Date('2026-07-30T11:45:00.000Z') }]), + }, + } as unknown as GamePrismaClient; + const values = new Map([ + [ + 'sammo:hwe:default:tournament:state', + JSON.stringify({ + stage: 1, + nextAt: '2026-07-30T12:00:00.000Z', + bettingCloseAt: '2026-07-30T11:30:00.000Z', + }), + ], + ]); + const zAdd = vi.fn(async () => 1); + const redis = { + get: async (key: string) => values.get(key) ?? null, + set: async ( + key: string, + value: string, + options?: { + NX?: boolean; + PX?: number; + } + ) => { + if (options?.NX && values.has(key)) { + return null; + } + values.set(key, value); + return 'OK'; + }, + del: async (key: string) => (values.delete(key) ? 1 : 0), + zAdd, + }; + const action = { + id: actionId, + profileName: 'hwe:default', + action: 'ACCELERATE', + durationMinutes: 15, + }; + + await expect(applyRuntimeClockShift({ action, profileName: 'hwe:default', db, redis })).resolves.toMatchObject({ + status: 'REQUESTED', + }); + expect(zAdd).not.toHaveBeenCalled(); + + eventStatus = 'SUCCEEDED'; + await expect(applyRuntimeClockShift({ action, profileName: 'hwe:default', db, redis })).resolves.toMatchObject({ + status: 'APPLIED', + }); + await expect(applyRuntimeClockShift({ action, profileName: 'hwe:default', db, redis })).resolves.toMatchObject({ + status: 'APPLIED', + }); + + const tournament = JSON.parse(values.get('sammo:hwe:default:tournament:state') ?? '{}') as Record< + string, + unknown + >; + expect(tournament).toMatchObject({ + nextAt: '2026-07-30T11:45:00.000Z', + bettingCloseAt: '2026-07-30T11:15:00.000Z', + runtimeClockShiftActionIds: [actionId], + }); + expect(zAdd).toHaveBeenCalledTimes(2); + expect(inputEventCreate).toHaveBeenCalledTimes(3); + }); +}); diff --git a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts new file mode 100644 index 0000000..b8f9243 --- /dev/null +++ b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts @@ -0,0 +1,264 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import { SystemClock } from '../src/lifecycle/clock.js'; +import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js'; +import { getNextTickTime } from '../src/lifecycle/getNextTickTime.js'; +import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js'; +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { EngineStateManager } from '../src/turn/engineStateManager.js'; +import { InMemoryTurnStateStore } from '../src/turn/inMemoryStateStore.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const requestId = 'integration:engine:runtime-clock-shift'; +const actionId = 'b9f68480-dba9-4e03-a62b-499e6234f18a'; +const generalIds = [990_301, 990_302] as const; + +const buildGeneral = (id: number, turnTime: Date): TurnGeneral => + ({ + id, + name: `시간조정${id}`, + nationId: 0, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + officerLevel: 0, + experience: 0, + dedication: 0, + injury: 0, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + }) as TurnGeneral; + +const waitForSucceeded = async (db: GamePrismaClient): Promise => { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const event = await db.inputEvent.findUnique({ where: { requestId }, select: { status: true } }); + if (event?.status === 'SUCCEEDED') { + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error('runtime clock shift input event did not complete'); +}; + +integration('runtime clock shift persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.inputEvent.deleteMany({ where: { requestId } }); + await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); + await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); + }); + + 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 closeDb?.(); + }); + + it('atomically shifts world, generals, and only OPEN auctions through the durable command path', async () => { + const base = new Date('2099-07-30T10:00:00.000Z'); + const row = await db.worldState.create({ + data: { + scenarioCode: 'runtime-clock-shift', + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { + lastTurnTime: base.toISOString(), + turntime: '2099-07-30 10:00:00', + starttime: '2099-07-01 00:00:00', + }, + }, + }); + const generals = [ + buildGeneral(generalIds[0], new Date('2099-07-30T10:10:00.000Z')), + buildGeneral(generalIds[1], new Date('2099-07-30T10:20:00.000Z')), + ]; + await db.general.createMany({ + data: generals.map((general) => ({ + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + turnTime: general.turnTime, + })), + }); + const auctionRows = await Promise.all( + (['OPEN', 'FINALIZING', 'FINISHED', 'CANCELED'] as const).map((status, index) => + db.auction.create({ + data: { + type: 'BUY_RICE', + hostGeneralId: generalIds[index % generalIds.length]!, + detail: {}, + status, + closeAt: new Date(`2099-07-30T1${index}:00:00.000Z`), + }, + }) + ) + ); + + const state: TurnWorldState = { + id: row.id, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: base, + meta: row.meta as Record, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals, + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const stateStore = new InMemoryTurnStateStore(world); + await stateStore.saveCheckpoint({ + turnTime: '2099-07-30T10:00:00.000Z', + generalId: 0, + year: 190, + month: 1, + }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const queue = new DatabaseTurnDaemonCommandQueue(db); + await queue.initialize(); + const stateManager = new EngineStateManager(); + stateManager.register('world', { + capture: () => world.captureState(), + restore: (saved) => world.restoreState(saved), + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock: new SystemClock(), + controlQueue: queue, + commandResponder: queue, + commandHandler: createTurnDaemonCommandHandler({ world }), + hooks: hooks.hooks, + stateManager, + stateStore, + getNextTickTime: (lastTurnTime) => getNextTickTime(lastTurnTime, 60), + processor: { + run: async () => ({ + lastTurnTime: world.getState().lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }), + }, + }, + { + profile: 'integration', + defaultBudget: { budgetMs: 1000, maxGenerals: 10, catchUpCap: 1 }, + } + ); + + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: 'shiftSchedule', + payload: { + type: 'shiftSchedule', + requestId, + actionId, + deltaMinutes: -15, + } as GamePrisma.InputJsonValue, + }, + }); + const loop = lifecycle.start(); + try { + await waitForSucceeded(db); + } finally { + await lifecycle.stop('test complete'); + await loop; + await hooks.close(); + } + + expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:45:00.000Z'); + expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:55:00.000Z'); + expect(await stateStore.loadCheckpoint()).toMatchObject({ + turnTime: '2099-07-30T09:45:00.000Z', + generalId: 0, + }); + expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z'); + expect((await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).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' + ); + const storedAuctions = await db.auction.findMany({ + where: { id: { in: auctionRows.map((auction) => auction.id) } }, + }); + const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()])); + expect(auctionRows.map((auction) => closeAtById.get(auction.id))).toEqual([ + '2099-07-30T09:45:00.000Z', + '2099-07-30T11:00:00.000Z', + '2099-07-30T12:00:00.000Z', + '2099-07-30T13:00:00.000Z', + ]); + expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({ + status: 'SUCCEEDED', + attempts: 1, + result: { + type: 'shiftSchedule', + ok: true, + actionId, + deltaMinutes: -15, + shiftedGenerals: 2, + shiftedAuctions: 1, + }, + }); + + await db.worldState.delete({ where: { id: row.id } }); + }); +}); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 2cd72c4..e16a1140 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -860,12 +860,27 @@ export const adminRouter = router({ profiles: router({ list: adminProcedure.query(async ({ ctx }) => { const profiles = await ctx.profiles.listProfiles(); + const runtimeActions = await ctx.prisma.gatewayRuntimeAction.findMany({ + where: { + profileName: { in: profiles.map((profile) => profile.profileName) }, + }, + orderBy: { createdAt: 'desc' }, + }); + const runtimeActionsByProfile = new Map(); + for (const action of runtimeActions) { + const bucket = runtimeActionsByProfile.get(action.profileName) ?? []; + if (bucket.length < 10) { + bucket.push(action); + runtimeActionsByProfile.set(action.profileName, bucket); + } + } const runtimeStates = await ctx.orchestrator.listRuntimeStates( profiles.map((profile) => profile.profileName) ); const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state])); return profiles.map((profile) => ({ ...profile, + runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [], runtime: runtimeMap.get(profile.profileName) ?? { profileName: profile.profileName, apiRunning: false, @@ -1251,6 +1266,12 @@ export const adminRouter = router({ message: 'scheduledAt is required for scheduled reset.', }); } + if (input.action !== 'RESET_SCHEDULED' && input.scheduledAt) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'scheduledAt is supported only for scheduled reset.', + }); + } const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ @@ -1307,6 +1328,36 @@ export const adminRouter = router({ }); } + if (input.action === 'OPEN_SURVEY') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '설문은 게임 내 설문 관리 화면에서 생성해 주세요.', + }); + } + + if (input.action === 'ACCELERATE' || input.action === 'DELAY') { + try { + const runtimeAction = await ctx.prisma.gatewayRuntimeAction.create({ + data: { + profileName: input.profileName, + action: input.action, + durationMinutes: input.durationMinutes, + reason: input.reason, + requestedBy: adminAuth.user.id, + }, + }); + return { ok: true, action: runtimeAction }; + } catch (error) { + if (!isUniqueConstraintError(error)) { + throw error; + } + throw new TRPCError({ + code: 'CONFLICT', + message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.', + }); + } + } + const statusMap = { RESUME: 'RUNNING', PAUSE: 'PAUSED', diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 8cf4566..dd413cc 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -12,7 +12,11 @@ import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; const buildCaller = async ( createOperation: GatewayProfileRepository['createOperation'], - options: { adminRoles?: string[]; firstUserIsAdmin?: boolean } = {} + options: { + adminRoles?: string[]; + firstUserIsAdmin?: boolean; + runtimeActionCreateError?: unknown; + } = {} ) => { const users = createInMemoryUserRepository(); const admin = await users.createUser({ @@ -28,6 +32,7 @@ const buildCaller = async ( }); const session = await sessions.createSession({ ...admin, roles: adminRoles }); const createdInputs: GatewayOperationCreateInput[] = []; + const createdRuntimeActions: Array> = []; const flushes: Array<{ userId: string; reason?: string }> = []; const profile = { profileName: 'che:2', @@ -104,10 +109,29 @@ const buildCaller = async ( appUser: { findFirst: async () => ({ id: options.firstUserIsAdmin === false ? 'bootstrap-user' : admin.id }), }, + gatewayRuntimeAction: { + create: async ({ data }: { data: Record }) => { + if (options.runtimeActionCreateError) { + throw options.runtimeActionCreateError; + } + createdRuntimeActions.push(data); + return { + id: '68f1f0e4-3b95-4aeb-9925-c7e93caf1ba7', + ...data, + status: 'REQUESTED', + detail: null, + handler: null, + handledAt: null, + scheduledAt: null, + createdAt: new Date('2026-07-30T01:00:00.000Z'), + updatedAt: new Date('2026-07-30T01:00:00.000Z'), + }; + }, + }, } as unknown as GatewayPrismaClient, }) ); - return { caller, createdInputs, users, admin, flushes }; + return { caller, createdInputs, createdRuntimeActions, users, admin, flushes }; }; describe('admin operation API', () => { @@ -152,6 +176,90 @@ describe('admin operation API', () => { }); }); +describe('admin runtime clock action API', () => { + const unusedCreateOperation: GatewayProfileRepository['createOperation'] = async () => { + throw new Error('not used'); + }; + + it('creates a first-class clock action owned by the authenticated administrator', async () => { + const harness = await buildCaller(unusedCreateOperation); + + const result = await harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'ACCELERATE', + durationMinutes: 15, + reason: '운영 일정 조정', + }); + + expect(result).toMatchObject({ + ok: true, + action: { + action: 'ACCELERATE', + durationMinutes: 15, + status: 'REQUESTED', + }, + }); + expect(harness.createdRuntimeActions).toEqual([ + { + profileName: 'che:2', + action: 'ACCELERATE', + durationMinutes: 15, + reason: '운영 일정 조정', + requestedBy: harness.admin.id, + }, + ]); + }); + + it('reports a conflict when another clock action is still pending', async () => { + const harness = await buildCaller(unusedCreateOperation, { + runtimeActionCreateError: { code: 'P2002' }, + }); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'DELAY', + durationMinutes: 5, + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.', + }); + }); + + it('rejects a scheduled clock shift instead of silently applying it immediately', async () => { + const harness = await buildCaller(unusedCreateOperation); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'ACCELERATE', + durationMinutes: 15, + scheduledAt: '2026-07-31T01:00:00.000Z', + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'scheduledAt is supported only for scheduled reset.', + }); + expect(harness.createdRuntimeActions).toEqual([]); + }); + + it('rejects OPEN_SURVEY instead of reporting a false success', async () => { + const harness = await buildCaller(unusedCreateOperation); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'OPEN_SURVEY', + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '설문은 게임 내 설문 관리 화면에서 생성해 주세요.', + }); + expect(harness.createdRuntimeActions).toEqual([]); + }); +}); + describe('admin role non-escalation', () => { const unusedCreateOperation: GatewayProfileRepository['createOperation'] = async () => { throw new Error('not used'); diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts new file mode 100644 index 0000000..dfe39af --- /dev/null +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -0,0 +1,217 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +type RuntimeAction = { + id: string; + action: 'ACCELERATE' | 'DELAY'; + durationMinutes: number; + status: 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED'; + detail: string; + handler: string | null; + handledAt: string | null; + createdAt: string; +}; + +const runtimeAction = (status: RuntimeAction['status'], overrides: Partial = {}): RuntimeAction => ({ + id: '68f1f0e4-3b95-4aeb-9925-c7e93caf1ba7', + action: 'ACCELERATE', + durationMinutes: 15, + status, + detail: `${status} 상세`, + handler: status === 'REQUESTED' ? null : 'turn-daemon', + handledAt: status === 'REQUESTED' ? null : '2026-07-30T01:00:01.000Z', + createdAt: '2026-07-30T01:00:00.000Z', + ...overrides, +}); + +const installFixture = async ( + page: Page, + options: { + deferRequest?: boolean; + initialActions?: RuntimeAction[]; + afterRequestActions?: RuntimeAction[]; + pendingProfileReads?: number; + } = {} +) => { + let requested = false; + let postRequestProfileReads = 0; + const requestBodies: unknown[] = []; + let releaseRequest = (): void => {}; + const requestGate = options.deferRequest + ? new Promise((resolve) => { + releaseRequest = resolve; + }) + : Promise.resolve(); + await page.addInitScript(() => { + window.localStorage.setItem('sammo-session-token', 'playwright-admin-session'); + }); + await page.route('**/gateway/api/trpc/**', async (route) => { + const body = route.request().postDataJSON() as unknown; + const operations = operationNames(route); + if (operations.includes('admin.profiles.requestAction')) { + requested = true; + requestBodies.push(body); + await requestGate; + } + const results = operations.map((operation) => { + if (operation === 'me') { + return response({ + id: 'admin-user', + username: 'admin', + displayName: '관리자', + roles: ['superuser'], + createdAt: '2026-07-30T00:00:00.000Z', + }); + } + if (operation === 'admin.system.getNotice') { + return response({ notice: '' }); + } + if (operation === 'admin.users.getLocalAccountStatus') { + return response({ enabled: true }); + } + if (operation === 'admin.profiles.listScenarios') { + return response([]); + } + if (operation === 'admin.profiles.list') { + const keepPending = requested && postRequestProfileReads++ < (options.pendingProfileReads ?? 0); + return response([ + { + profileName: 'hwe:default', + profile: 'hwe', + scenario: 'default', + apiPort: 15015, + status: 'RUNNING', + buildStatus: 'SUCCEEDED', + meta: {}, + runtime: { + profileName: 'hwe:default', + apiRunning: true, + daemonRunning: true, + auctionRunning: true, + battleSimRunning: true, + tournamentRunning: true, + }, + runtimeActions: keepPending + ? [runtimeAction('REQUESTED')] + : requested + ? (options.afterRequestActions ?? [ + runtimeAction('APPLIED', { + detail: '15분 가속 · 장수 2명 · 경매 1건', + }), + ]) + : (options.initialActions ?? []), + }, + ]); + } + if (operation === 'admin.profiles.requestAction') { + return response({ + ok: true, + action: { + id: '68f1f0e4-3b95-4aeb-9925-c7e93caf1ba7', + action: 'ACCELERATE', + durationMinutes: 15, + status: 'REQUESTED', + detail: null, + handler: null, + handledAt: null, + createdAt: '2026-07-30T01:00:00.000Z', + }, + }); + } + throw new Error(`Unhandled tRPC operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); + return { releaseRequest, requestBodies }; +}; + +test('reports clock-shift acceptance separately from actual application', async ({ page }) => { + const fixture = await installFixture(page, { deferRequest: true, pendingProfileReads: 1 }); + await page.goto('admin'); + await expect(page.getByRole('heading', { name: '관리자 콘솔' })).toBeVisible(); + + const duration = page.locator('input[type="number"][min="1"][max="1440"]'); + const accelerate = page.getByRole('button', { name: '가속', exact: true }); + await expect(accelerate).toBeDisabled(); + await duration.fill('1.5'); + await expect(accelerate).toBeDisabled(); + await duration.fill('15'); + await expect(accelerate).toBeEnabled(); + + const click = accelerate.click(); + await expect.poll(() => fixture.requestBodies.length).toBe(1); + await expect(accelerate).toBeDisabled(); + await expect(page.getByRole('button', { name: '연기', exact: true })).toBeDisabled(); + fixture.releaseRequest(); + await click; + + await expect(page.getByText('APPLIED · ACCELERATE 15분')).toBeVisible(); + await expect(page.getByText('15분 가속 · 장수 2명 · 경매 1건')).toBeVisible(); + await expect(page.getByText(/요청 완료: ACCELERATE/)).toHaveCount(0); + expect(fixture.requestBodies).toHaveLength(1); + expect(JSON.stringify(fixture.requestBodies[0])).toContain('"ACCELERATE"'); + expect(JSON.stringify(fixture.requestBodies[0])).toContain('"durationMinutes":15'); + await expect(page.getByRole('button', { name: '설문 오픈 (게임 내 관리)' })).toBeDisabled(); + await expect(page.getByText('설문 생성은 해당 게임의 설문 관리 화면에서 진행해 주세요.')).toBeVisible(); +}); + +test('blocks another clock shift while any recent action is pending', async ({ page }) => { + await installFixture(page, { + initialActions: [ + runtimeAction('APPLIED', { createdAt: '2026-07-30T01:00:02.000Z' }), + runtimeAction('PARTIAL', { + id: '5a971e6e-03e2-45ff-bcab-c5cbdacb21d3', + createdAt: '2026-07-30T01:00:01.000Z', + }), + ], + }); + await page.goto('admin'); + await page.locator('input[type="number"][min="1"][max="1440"]').fill('15'); + + await expect(page.getByRole('button', { name: '가속', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: '연기', exact: true })).toBeDisabled(); +}); + +test('renders a failed terminal outcome without calling it applied', async ({ page }) => { + await installFixture(page, { + initialActions: [ + runtimeAction('FAILED', { + detail: 'DB 시간 조정 실패', + }), + ], + }); + await page.goto('admin'); + + const failed = page.getByText('FAILED · ACCELERATE 15분'); + await expect(failed).toBeVisible(); + await expect(page.getByText('DB 시간 조정 실패')).toBeVisible(); + expect(await failed.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)'); + await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0); +}); + +test('renders an ignored terminal outcome without calling it applied', async ({ page }) => { + await installFixture(page, { + initialActions: [ + runtimeAction('IGNORED', { + action: 'DELAY', + detail: '지원하지 않는 요청', + }), + ], + }); + await page.goto('admin'); + + const ignored = page.getByText('IGNORED · DELAY 15분'); + await expect(ignored).toBeVisible(); + expect(await ignored.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.75 0.183 55.934)'); + await expect(page.getByText('지원하지 않는 요청')).toBeVisible(); + await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0); +}); diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index 8b2570e..5412030 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -6,7 +6,12 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../. export default defineConfig({ testDir: '.', - testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts', 'logout.spec.ts'], + testMatch: [ + 'server-operations.spec.ts', + 'admin-runtime-actions.spec.ts', + 'lobby-admin-navigation.spec.ts', + 'logout.spec.ts', + ], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 05a3fca..32942dd 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -76,6 +76,16 @@ type AdminProfile = { }; buildCommitSha?: string; meta: Record; + runtimeActions: Array<{ + id: string; + action: string; + durationMinutes: number | null; + status: 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED'; + detail: string | null; + handler: string | null; + handledAt: string | null; + createdAt: string; + }>; }; type ScenarioNationPreview = { @@ -235,7 +245,7 @@ type AdminClient = { durationMinutes?: number; scheduledAt?: string; reason?: string; - }) => Promise<{ ok: boolean }>; + }) => Promise<{ ok: boolean; action?: AdminProfile['runtimeActions'][number] }>; }; }; }; @@ -290,10 +300,34 @@ const profileActions = ref< > >({}); const profileActionStatus = ref>({}); +const profileActionSubmitting = ref>({}); const scenarioCatalogs = ref>({}); const profileInstalls = ref>({}); const profileInstallStatus = ref>({}); +const runtimeActionPending = (profile: AdminProfile): boolean => { + return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL'); +}; + +const validDuration = (profileName: string): boolean => { + const value = Number(profileActions.value[profileName]?.durationMinutes); + return Number.isInteger(value) && value >= 1 && value <= 1440; +}; + +const runtimeActionStatusClass = (status: AdminProfile['runtimeActions'][number]['status']): string => { + if (status === 'APPLIED') return 'text-emerald-400'; + if (status === 'FAILED') return 'text-red-400'; + if (status === 'IGNORED') return 'text-orange-400'; + if (status === 'PARTIAL') return 'text-amber-400'; + return 'text-zinc-400'; +}; + +const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean => + status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED'; + +const formatRuntimeActionTime = (value: string | null): string => + value ? new Date(value).toLocaleString('ko-KR') : ''; + const autorunOptionLabels = [ { key: 'develop', label: '내정' }, { key: 'warp', label: '순간이동' }, @@ -557,6 +591,13 @@ const loadProfiles = async () => { result.forEach((profile) => { ensureProfileBuffers(profile); ensureProfileInstallBuffers(profile); + const latest = profile.runtimeActions[0]; + if (latest && isRuntimeActionTerminal(latest.status)) { + profileActionStatus.value = { + ...profileActionStatus.value, + [profile.profileName]: '', + }; + } }); profiles.value = result; const refs = new Set(); @@ -578,6 +619,32 @@ const loadProfiles = async () => { } }; +const refreshRuntimeActionUntilTerminal = async (profileName: string, actionId: string): Promise => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const current = profiles.value + .find((profile) => profile.profileName === profileName) + ?.runtimeActions.find((action) => action.id === actionId); + if (current && isRuntimeActionTerminal(current.status)) { + profileActionStatus.value = { + ...profileActionStatus.value, + [profileName]: '', + }; + return; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + try { + const result = await adminClient.profiles.list.query(); + result.forEach((profile) => { + ensureProfileBuffers(profile); + ensureProfileInstallBuffers(profile); + }); + profiles.value = result; + } catch { + // 일시적인 조회 실패는 다음 bounded poll에서 다시 확인합니다. + } + } +}; + const loadScenarioCatalog = async (gitRef: string) => { const key = getScenarioCatalogKey(gitRef); const previous = scenarioCatalogs.value[key]; @@ -663,13 +730,26 @@ const updateProfileMeta = async (profileName: string) => { }; const requestProfileAction = async (profileName: string, action: AdminAction) => { + if (profileActionSubmitting.value[profileName]) { + return; + } + profileActionSubmitting.value = { + ...profileActionSubmitting.value, + [profileName]: true, + }; const actionState = profileActions.value[profileName]; - const durationMinutes = actionState?.durationMinutes ? Number(actionState.durationMinutes) : undefined; - const durationValue = durationMinutes && durationMinutes > 0 ? durationMinutes : undefined; - const scheduledAt = actionState?.scheduledAt ? new Date(actionState.scheduledAt).toISOString() : undefined; + const timeShiftAction = action === 'ACCELERATE' || action === 'DELAY'; + const durationMinutes = + timeShiftAction && actionState?.durationMinutes ? Number(actionState.durationMinutes) : undefined; + const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined; + const scheduledAt = + action === 'RESET_SCHEDULED' && actionState?.scheduledAt + ? new Date(actionState.scheduledAt).toISOString() + : undefined; const reason = actionState?.reason.trim() || undefined; + let runtimeActionId: string | undefined; try { - await adminClient.profiles.requestAction.mutate({ + const result = await adminClient.profiles.requestAction.mutate({ profileName, action, durationMinutes: durationValue, @@ -678,13 +758,28 @@ const requestProfileAction = async (profileName: string, action: AdminAction) => }); profileActionStatus.value = { ...profileActionStatus.value, - [profileName]: `요청 완료: ${action}`, + [profileName]: + result.action && (action === 'ACCELERATE' || action === 'DELAY') + ? `접수됨 · ${result.action.status} · ${action} ${result.action.durationMinutes ?? ''}분` + : `요청 접수: ${action}`, }; + if (result.action) { + runtimeActionId = result.action.id; + await loadProfiles(); + } } catch (error) { profileActionStatus.value = { ...profileActionStatus.value, [profileName]: `요청 실패: ${action}`, }; + } finally { + profileActionSubmitting.value = { + ...profileActionSubmitting.value, + [profileName]: false, + }; + } + if (runtimeActionId) { + void refreshRuntimeActionUntilTerminal(profileName, runtimeActionId); } }; @@ -1414,14 +1509,33 @@ onMounted(() => { class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white" placeholder="사유 / 메모" /> - + +
+ 1~1440 사이의 정수로 입력해 주세요. +
{ 중지 diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 370c899..1647439 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -26,6 +26,7 @@ Gateway API는 다음 저장 경계를 사용합니다. - `AppUser`, `SystemSetting`: 계정과 정책 - `GatewayProfile`: profile, scenario, port, 상태와 build 결과 - `GatewayOperation`: build/reset/open/close 등 실행 요청과 결과 +- `GatewayRuntimeAction`: profile별 시간 가속·연기 요청, 부분 적용과 최종 결과 - Redis: gateway session, OAuth 임시 상태, flush channel Orchestrator는 `GatewayOperation`을 claim하고 source ref를 commit으로 @@ -33,6 +34,15 @@ Orchestrator는 `GatewayOperation`을 claim하고 source ref를 commit으로 artifact를 만들며 `Pm2ProcessManager`가 profile process를 조정합니다. 재시작 시 DB 상태와 process 상태를 reconciliation합니다. +시간 가속·연기는 일반 profile meta log가 아니라 UUID가 있는 +`GatewayRuntimeAction`으로 접수합니다. Profile별 `REQUESTED`/`PARTIAL`은 +DB partial unique index로 한 건만 허용합니다. Turn daemon은 자신의 lease를 +확인한 뒤 action ID로 결정적인 `InputEvent`를 만들고, world·전 장수·OPEN +경매·checkpoint를 같은 PostgreSQL transaction에서 이동합니다. Commit 뒤 +경매 timer와 활성 토너먼트 시각을 Redis에 idempotent하게 투영합니다. +Redis 단계가 실패하면 action은 `PARTIAL`과 backoff 상태로 남고 DB 시간은 +다시 이동하지 않습니다. + ## Game API 실행 `resolveGameApiConfigFromEnv()`가 `PROFILE`, `SCENARIO`, @@ -120,3 +130,10 @@ Gateway operation은 source commit, worktree, build artifact와 process를 외부 공개 경로는 `/gateway/`, `/che/`, `/hwe/`입니다. frontend base, tRPC, SSE, upload와 direct navigation은 해당 prefix를 유지합니다. `/image/*`는 Caddy의 별도 파일 시스템 경로입니다. + +Game과 gateway가 같은 PostgreSQL database/schema를 사용할 때 migration은 +반드시 game 다음 gateway 순서로 적용합니다. 두 migration history는 하나의 +`_prisma_migrations`를 공유하므로 새 migration directory 이름은 양쪽을 +통틀어 고유해야 합니다. 과거 양쪽의 +`20260727000000_add_legacy_migration_archive` 이름 충돌은 checksum을 바꾸지 +않고 별도의 idempotent reconciliation migration으로 보정합니다. diff --git a/docs/integration-tests.md b/docs/integration-tests.md index f7a99fa..31cc1a9 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -57,10 +57,19 @@ runtime role을 삭제하고 PID와 명령행 및 daemon 종료를 확인한 뒤 - auth header, role, sanction과 owner별 HTTP transport - ref/core command snapshot, RNG trace와 persistence - auction, tournament와 worker transaction +- 관리자 시간 가속·연기의 durable action, checkpoint, Redis 부분 재시도와 + 경매 timer race 실제 포함 suite는 `tools/run-conditional-integration.sh`, 각 package의 `package.json`, `*.integration.test.ts`를 기준으로 확인합니다. +관리자 시간 조정의 PostgreSQL 경계는 +`runtimeClockShiftPersistence.integration.test.ts`, gateway action +`PARTIAL → APPLIED` 경계는 `gatewayRuntimeAction.integration.test.ts`입니다. +후자는 `GATEWAY_RUNTIME_ACTION_DATABASE_URL`, 전자는 +`INPUT_EVENT_DATABASE_URL`이 없으면 skip되므로 결과에서 실제 실행 여부를 +따로 확인해 주세요. + ## 안전 경계 - Test는 game schema table을 truncate할 수 있습니다. diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index d71d178..7c8f325 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -51,6 +51,12 @@ export type TurnDaemonCommand = | { type: 'pause'; requestId?: string; reason?: string } | { type: 'resume'; requestId?: string; reason?: string } | { type: 'shutdown'; requestId?: string; reason?: string } + | { + type: 'shiftSchedule'; + requestId?: string; + actionId: string; + deltaMinutes: number; + } | { type: 'getStatus'; requestId?: string } | { type: 'troopCreate'; requestId?: string; generalId: number; troopName: string } | { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number } @@ -214,6 +220,22 @@ export type TurnDaemonCommandResult = commandType: TurnDaemonCommand['type']; reason: string; } + | { + type: 'shiftSchedule'; + ok: true; + actionId: string; + deltaMinutes: number; + lastTurnTime: string; + shiftedGenerals: number; + shiftedAuctions: number; + checkpoint?: TurnCheckpoint; + } + | { + type: 'shiftSchedule'; + ok: false; + actionId: string; + reason: string; + } | { type: 'auctionFinalize'; ok: true; diff --git a/packages/infra/prisma/gateway-migrations/20260730000000_add_runtime_actions/migration.sql b/packages/infra/prisma/gateway-migrations/20260730000000_add_runtime_actions/migration.sql new file mode 100644 index 0000000..244c1c2 --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260730000000_add_runtime_actions/migration.sql @@ -0,0 +1,42 @@ +CREATE TYPE "GatewayRuntimeActionStatus" AS ENUM ( + 'REQUESTED', + 'PARTIAL', + 'APPLIED', + 'FAILED', + 'IGNORED' +); + +CREATE TABLE "gateway_runtime_action" ( + "id" TEXT NOT NULL, + "profile_name" TEXT NOT NULL, + "action" TEXT NOT NULL, + "duration_minutes" INTEGER, + "scheduled_at" TIMESTAMP(3), + "reason" TEXT, + "requested_by" TEXT NOT NULL, + "status" "GatewayRuntimeActionStatus" NOT NULL DEFAULT 'REQUESTED', + "detail" TEXT, + "handler" TEXT, + "handled_at" TIMESTAMP(3), + "attempts" INTEGER NOT NULL DEFAULT 0, + "next_attempt_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "gateway_runtime_action_pkey" PRIMARY KEY ("id"), + CONSTRAINT "gateway_runtime_action_profile_name_fkey" + FOREIGN KEY ("profile_name") + REFERENCES "gateway_profile"("profile_name") + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX "gateway_runtime_action_profile_name_status_created_at_idx" + ON "gateway_runtime_action"("profile_name", "status", "created_at"); + +CREATE INDEX "gateway_runtime_action_profile_name_created_at_idx" + ON "gateway_runtime_action"("profile_name", "created_at"); + +CREATE UNIQUE INDEX "gateway_runtime_action_one_pending_per_profile_idx" + ON "gateway_runtime_action"("profile_name") + WHERE "status" IN ('REQUESTED', 'PARTIAL'); diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index 1075085..3c8e99a 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -45,6 +45,14 @@ enum GatewayOperationStatus { CANCELLED } +enum GatewayRuntimeActionStatus { + REQUESTED + PARTIAL + APPLIED + FAILED + IGNORED +} + enum GatewaySourceMode { BRANCH COMMIT @@ -113,32 +121,56 @@ model LegacyRootKeyValue { } model GatewayProfile { - profileName String @id @map("profile_name") + profileName String @id @map("profile_name") profile String scenario String - apiPort Int @map("api_port") + apiPort Int @map("api_port") status GatewayProfileStatus - buildStatus GatewayBuildStatus @default(IDLE) @map("build_status") - buildCommitSha String? @map("build_commit_sha") - buildWorkspace String? @map("build_workspace") - buildLastUsedAt DateTime? @map("build_last_used_at") - preopenAt DateTime? @map("preopen_at") - openAt DateTime? @map("open_at") - scheduledStartAt DateTime? @map("scheduled_start_at") - buildRequestedAt DateTime? @map("build_requested_at") - buildStartedAt DateTime? @map("build_started_at") - buildCompletedAt DateTime? @map("build_completed_at") - buildError String? @map("build_error") - lastError String? @map("last_error") - meta Json @default(dbgenerated("'{}'::jsonb")) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + buildStatus GatewayBuildStatus @default(IDLE) @map("build_status") + buildCommitSha String? @map("build_commit_sha") + buildWorkspace String? @map("build_workspace") + buildLastUsedAt DateTime? @map("build_last_used_at") + preopenAt DateTime? @map("preopen_at") + openAt DateTime? @map("open_at") + scheduledStartAt DateTime? @map("scheduled_start_at") + buildRequestedAt DateTime? @map("build_requested_at") + buildStartedAt DateTime? @map("build_started_at") + buildCompletedAt DateTime? @map("build_completed_at") + buildError String? @map("build_error") + lastError String? @map("last_error") + meta Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") operations GatewayOperation[] + runtimeActions GatewayRuntimeAction[] @@unique([profile, scenario]) @@map("gateway_profile") } +model GatewayRuntimeAction { + id String @id @default(uuid()) + profileName String @map("profile_name") + profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade) + action String + durationMinutes Int? @map("duration_minutes") + scheduledAt DateTime? @map("scheduled_at") + reason String? + requestedBy String @map("requested_by") + status GatewayRuntimeActionStatus @default(REQUESTED) + detail String? + handler String? + handledAt DateTime? @map("handled_at") + attempts Int @default(0) + nextAttemptAt DateTime? @map("next_attempt_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([profileName, status, createdAt]) + @@index([profileName, createdAt]) + @@map("gateway_runtime_action") +} + model GatewayOperation { id String @id @default(uuid()) profileName String @map("profile_name")