diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 8a9e183a..5ee64911 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -348,7 +348,7 @@ export class TurnDaemonLifecycle { return; } - if (result.type === 'shiftSchedule' && result.ok) { + if ((result.type === 'shiftSchedule' || result.type === 'updateRuntimeSettings') && result.ok) { this.status.lastTurnTime = result.lastTurnTime; this.status.checkpoint = result.checkpoint; await this.stateStore.saveCheckpoint(result.checkpoint); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 4ddb8499..74cc5efc 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -368,6 +368,30 @@ const zShiftSchedule = z.object({ .refine((value) => value !== 0), }); +const zRuntimeAutorunOption = z.enum(['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief']); + +const zUpdateRuntimeSettings = z.object({ + type: z.literal('updateRuntimeSettings'), + actionId: z.string().uuid(), + settings: z + .object({ + turnTermMinutes: z + .number() + .int() + .refine((value) => [1, 2, 5, 10, 20, 30, 60, 120].includes(value)) + .optional(), + blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), + autorunUser: z + .object({ + limitMinutes: z.number().int().min(1).max(43200), + options: z.array(zRuntimeAutorunOption).min(1), + }) + .nullable() + .optional(), + }) + .refine((settings) => Object.values(settings).some((value) => value !== undefined)), +}); + const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope) => { const command = parseWith(zAuctionFinalize, envelope.command); if (!command) { @@ -660,6 +684,14 @@ const normalizeShiftSchedule: CommandNormalizer<'shiftSchedule'> = (envelope) => return { ...command, requestId: envelope.requestId }; }; +const normalizeUpdateRuntimeSettings: CommandNormalizer<'updateRuntimeSettings'> = (envelope) => { + const command = parseWith(zUpdateRuntimeSettings, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizers: CommandNormalizerMap = { auctionFinalize: normalizeAuctionFinalize, auctionOpen: normalizeAuctionOpen, @@ -699,6 +731,7 @@ const normalizers: CommandNormalizerMap = { resume: normalizeResume, shutdown: normalizeShutdown, shiftSchedule: normalizeShiftSchedule, + updateRuntimeSettings: normalizeUpdateRuntimeSettings, }; export const normalizeTurnDaemonCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonCommand | null => { diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 65dc7eea..d5cdd3d5 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -149,7 +149,7 @@ export const createWorldReadModelSignature = (world: InMemoryTurnWorld): string currentYear: state.currentYear, currentMonth: state.currentMonth, tickSeconds: state.tickSeconds, - config: world.getScenarioConfig(), + config: world.getWorldConfig(), meta: projectWorldMeta(state.meta), }); }; @@ -438,11 +438,7 @@ export const createReadModelChangeJournal = (changes: RealtimeReadModelChanges): if (changes.worldChanged) { journal.mark('world.content').mark('map.world'); } - if ( - changes.mapChanged || - (changes.mapCityIds ?? []).length > 0 || - (changes.mapNationIds ?? []).length > 0 - ) { + if (changes.mapChanged || (changes.mapCityIds ?? []).length > 0 || (changes.mapNationIds ?? []).length > 0) { journal.mark('map.world'); } if ((changes.frontStatusGeneralIds ?? []).length > 0 || changes.frontStatusChanged) { @@ -1119,6 +1115,7 @@ export const createDatabaseTurnHooks = async ( clockMode: state.clockMode ?? 'manual', clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime, lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)), + config: asJson(world.getWorldConfig()), meta: asJson(state.meta), }; const persist = async ( diff --git a/app/game-engine/src/turn/gatewayAdminActions.ts b/app/game-engine/src/turn/gatewayAdminActions.ts index 068eeaa0..2607ab6a 100644 --- a/app/game-engine/src/turn/gatewayAdminActions.ts +++ b/app/game-engine/src/turn/gatewayAdminActions.ts @@ -15,6 +15,7 @@ export interface GatewayAdminActionRecord { handledAt?: string | null; handler?: string | null; detail?: string | null; + payload?: Record; install?: { scenarioId?: number; turnTermMinutes?: number; @@ -103,6 +104,7 @@ export const createGatewayAdminActionConsumer = async ( handledAt: action.handledAt?.toISOString() ?? null, handler: action.handler, detail: action.detail, + payload: normalizeMeta(action.payload), }; let result: GatewayAdminActionResult; try { diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index 2e438782..ece475c9 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -33,13 +33,13 @@ const isWorldUnited = (world: InMemoryTurnWorld): boolean => { export class InMemoryTurnProcessor implements TurnProcessor { // 인메모리 월드로 턴을 실행하고 월/연 갱신까지 처리한다. private readonly world: InMemoryTurnWorld; - private readonly tickMinutes: number; + private readonly tickMinutesOverride?: number; private readonly beforeExecuteGeneral?: (general: TurnGeneral) => Promise; private readonly afterExecuteGeneral?: (general: TurnGeneral, result: TurnGeneralExecutionResult) => Promise; constructor(world: InMemoryTurnWorld, options: InMemoryTurnProcessorOptions = {}) { this.world = world; - this.tickMinutes = resolveTickMinutes(world, options.tickMinutes); + this.tickMinutesOverride = options.tickMinutes; this.beforeExecuteGeneral = options.beforeExecuteGeneral; this.afterExecuteGeneral = options.afterExecuteGeneral; } @@ -53,6 +53,7 @@ export class InMemoryTurnProcessor implements TurnProcessor { this.world.updateWorldMeta({ refreshLimit: calculateAccessRefreshLimit(this.world.getState().tickSeconds), }); + const tickMinutes = resolveTickMinutes(this.world, this.tickMinutesOverride); let processedGenerals = 0; let processedTurns = 0; @@ -61,7 +62,7 @@ export class InMemoryTurnProcessor implements TurnProcessor { let nextCheckpoint: TurnCheckpoint | undefined = undefined; const previousLastTurnTime = this.world.getState().lastTurnTime; - const firstTickTime = getNextTickTime(previousLastTurnTime, this.tickMinutes); + const firstTickTime = getNextTickTime(previousLastTurnTime, tickMinutes); // 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. @@ -114,7 +115,7 @@ export class InMemoryTurnProcessor implements TurnProcessor { } if (!partial) { - let nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes); + let nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, tickMinutes); while (!isWorldUnited(this.world) && nextTickTime.getTime() <= targetTime.getTime()) { if (processedTurns >= budget.catchUpCap || isBudgetExpired()) { partial = true; @@ -125,7 +126,7 @@ export class InMemoryTurnProcessor implements TurnProcessor { if (isWorldUnited(this.world)) { break; } - nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes); + nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, tickMinutes); } } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 3831011b..a6ea81f8 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -145,6 +145,7 @@ export interface TurnWorldChanges { export interface InMemoryTurnWorldStateSnapshot { schedule: TurnSchedule; state: TurnWorldState; + worldConfig: Record; checkpoint?: TurnCheckpoint; generals: Array<[number, TurnGeneral]>; cities: Array<[number, City]>; @@ -181,6 +182,7 @@ export interface InMemoryTurnWorldStateSnapshot { export interface InMemoryTurnWorldInspection { state: TurnWorldState; + worldConfig: Record; checkpoint?: TurnCheckpoint; generals: TurnGeneral[]; cities: City[]; @@ -447,6 +449,7 @@ export class InMemoryTurnWorld { private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = []; private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = []; private readonly scenarioConfig: ScenarioConfig; + private readonly worldConfig: Record; private readonly unitSet?: UnitSetDefinition; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -483,6 +486,10 @@ export class InMemoryTurnWorld { meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() }, }; this.scenarioConfig = snapshot.scenarioConfig; + // Runtime callbacks created before the world keep the original object + // reference. Mutate this object in place so a live settings action is + // observed by monthly handlers without restarting the daemon. + this.worldConfig = snapshot.worldConfig ?? {}; this.unitSet = snapshot.unitSet; this.schedule = options.schedule; this.generalTurnHandler = @@ -597,6 +604,7 @@ export class InMemoryTurnWorld { return structuredClone({ schedule: this.schedule, state: this.state, + worldConfig: this.worldConfig, checkpoint: this.checkpoint, generals: Array.from(this.generals.entries()), cities: Array.from(this.cities.entries()), @@ -636,6 +644,10 @@ export class InMemoryTurnWorld { const restored = structuredClone(snapshot); this.schedule = restored.schedule; this.state = restored.state; + for (const key of Object.keys(this.worldConfig)) { + delete this.worldConfig[key]; + } + Object.assign(this.worldConfig, restored.worldConfig); this.checkpoint = restored.checkpoint; this.replaceMap(this.generals, restored.generals); this.replaceMap(this.cities, restored.cities); @@ -673,6 +685,7 @@ export class InMemoryTurnWorld { inspectState(): InMemoryTurnWorldInspection { return structuredClone({ state: this.state, + worldConfig: this.worldConfig, checkpoint: this.checkpoint, generals: Array.from(this.generals.values()), cities: Array.from(this.cities.values()), @@ -698,46 +711,107 @@ export class InMemoryTurnWorld { }; } + updateWorldConfig(patch: Record): void { + Object.assign(this.worldConfig, patch); + } + markGeneralAccessScoreReset(generalId: number): void { if (Number.isSafeInteger(generalId) && generalId > 0) { this.accessScoreResetGeneralIds.add(generalId); } } - changeTurnTerm(tickMinutes: number): void { + changeTurnTerm( + tickMinutes: number, + wallNow = new Date() + ): { + changed: boolean; + previousTurnTermMinutes: number; + turnTermMinutes: number; + previousClockBaseTime: string; + clockBaseTime: string; + shiftedGenerals: number; + lastTurnTime: string; + } { if (!Number.isInteger(tickMinutes) || tickMinutes <= 0) { throw new Error('Turn term must be a positive integer.'); } const previousTickSeconds = this.state.tickSeconds; const nextTickSeconds = tickMinutes * 60; - if (previousTickSeconds === nextTickSeconds) { - return; - } const previousClock = this.getGameClock(); - const anchorTick = this.state.clockTick ?? previousClock.tick; + const previousClockBaseTime = previousClock.baseTime.toISOString(); + if (previousTickSeconds === nextTickSeconds) { + this.updateWorldConfig({ turnTermMinutes: tickMinutes }); + return { + changed: false, + previousTurnTermMinutes: previousTickSeconds / 60, + turnTermMinutes: tickMinutes, + previousClockBaseTime, + clockBaseTime: previousClockBaseTime, + shiftedGenerals: 0, + lastTurnTime: this.state.lastTurnTime.toISOString(), + }; + } + const currentWallAnchor = this.state.clockWallAnchor ?? previousClock.wallAnchor; + const anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow; + const anchorTick = previousClock.nowTick(anchorWall); const anchorDisplay = previousClock.tickToDate(anchorTick); const nextBaseTime = GameClock.baseTimeForProjection(anchorDisplay, anchorTick, nextTickSeconds); - const ratio = nextTickSeconds / previousTickSeconds; - const baseTime = this.state.lastTurnTime.getTime(); - const nextGeneralTimes = new Map( - Array.from(this.generals.values(), (general) => [ - general.id, - new Date(baseTime + (general.turnTime.getTime() - baseTime) * ratio), - ]) - ); + const nextClock = new GameClock({ + baseTime: nextBaseTime, + tick: anchorTick, + mode: this.state.clockMode ?? 'manual', + wallAnchor: anchorWall, + turnSeconds: nextTickSeconds, + }); + const lastTurnTick = this.state.lastTurnTick ?? previousClock.dateToTick(this.state.lastTurnTime); + const nextLastTurnTime = nextClock.tickToDate(lastTurnTick); this.schedule = { entries: [{ startMinute: 0, tickMinutes }] }; this.state = { ...this.state, tickSeconds: nextTickSeconds, clockBaseTime: nextBaseTime, + clockTick: anchorTick, + clockWallAnchor: new Date(anchorWall.getTime()), + lastTurnTick, + lastTurnTime: nextLastTurnTime, + meta: { + ...this.state.meta, + turnterm: tickMinutes, + lastTurnTime: nextLastTurnTime.toISOString(), + }, }; + this.updateWorldConfig({ turnTermMinutes: tickMinutes }); 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 }); + const turnTick = general.turnTick ?? previousClock.dateToTick(general.turnTime); + const recentWarTick = + general.recentWarTick ?? + (general.recentWarTime ? previousClock.dateToTick(general.recentWarTime) : null); + this.updateGeneral(general.id, { + turnTick, + turnTime: nextClock.tickToDate(turnTick), + recentWarTick, + recentWarTime: recentWarTick === null ? null : nextClock.tickToDate(recentWarTick), + }); } + if (this.checkpoint) { + const checkpointTick = + this.checkpoint.turnTick ?? previousClock.dateToTick(new Date(this.checkpoint.turnTime)); + this.checkpoint = { + ...this.checkpoint, + turnTick: checkpointTick, + turnTime: nextClock.tickToDate(checkpointTick).toISOString(), + }; + } + return { + changed: true, + previousTurnTermMinutes: previousTickSeconds / 60, + turnTermMinutes: tickMinutes, + previousClockBaseTime, + clockBaseTime: nextBaseTime.toISOString(), + shiftedGenerals: this.generals.size, + lastTurnTime: nextLastTurnTime.toISOString(), + }; } pushLog(entry: LogEntryDraft): void { @@ -793,6 +867,10 @@ export class InMemoryTurnWorld { return this.scenarioConfig; } + getWorldConfig(): Record { + return this.worldConfig; + } + getUnitSet(): UnitSetDefinition | undefined { return this.unitSet; } diff --git a/app/game-engine/src/turn/runtimeGameSettings.ts b/app/game-engine/src/turn/runtimeGameSettings.ts new file mode 100644 index 00000000..08bee2d1 --- /dev/null +++ b/app/game-engine/src/turn/runtimeGameSettings.ts @@ -0,0 +1,244 @@ +import { randomUUID } from 'node:crypto'; + +import { + buildGameEventChannel, + GameClock, + isRecord, + writeTournamentProjection, + type RuntimeAutorunUserOption, + type RuntimeGameSettingsPatch, + type TurnDaemonCommand, + type TurnDaemonCommandResult, +} from '@sammo-ts/common'; +import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; + +import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js'; + +interface RuntimeSettingsRedisClient { + get(key: string): Promise; + set(key: string, value: string, options?: { NX?: boolean; PX?: number }): Promise; + del(key: string): Promise; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; + publish?(channel: string, message: string): Promise; +} + +type TournamentClockState = { + nextAt?: string; + bettingCloseAt?: string; + runtimeSettingsActionIds?: string[]; + [key: string]: unknown; +}; + +const TURN_TERMS = new Set([1, 2, 5, 10, 20, 30, 60, 120]); +const AUTORUN_OPTIONS = new Set([ + 'develop', + 'warp', + 'recruit', + 'recruit_high', + 'train', + 'battle', + 'chief', +]); +const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; +const isUniqueConflict = (error: unknown): boolean => + typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const normalizeSettings = (value: unknown): RuntimeGameSettingsPatch | null => { + if (!isRecord(value)) return null; + const settings: RuntimeGameSettingsPatch = {}; + if (value.turnTermMinutes !== undefined) { + if (!Number.isInteger(value.turnTermMinutes) || !TURN_TERMS.has(value.turnTermMinutes as number)) return null; + settings.turnTermMinutes = value.turnTermMinutes as number; + } + if (value.blockGeneralCreate !== undefined) { + if (![0, 1, 2].includes(value.blockGeneralCreate as number)) return null; + settings.blockGeneralCreate = value.blockGeneralCreate as 0 | 1 | 2; + } + if (value.autorunUser !== undefined) { + if (value.autorunUser === null) { + settings.autorunUser = null; + } else { + if (!isRecord(value.autorunUser)) return null; + const limitMinutes = value.autorunUser.limitMinutes; + const options = value.autorunUser.options; + if ( + !Number.isInteger(limitMinutes) || + (limitMinutes as number) < 1 || + (limitMinutes as number) > 43200 || + !Array.isArray(options) || + options.length === 0 || + !options.every( + (option): option is RuntimeAutorunUserOption => + typeof option === 'string' && AUTORUN_OPTIONS.has(option as RuntimeAutorunUserOption) + ) + ) { + return null; + } + settings.autorunUser = { + limitMinutes: limitMinutes as number, + options: Array.from(new Set(options)), + }; + } + } + return Object.keys(settings).length > 0 ? settings : null; +}; + +const ensureEngineCommand = async ( + db: GamePrismaClient, + actionId: string, + settings: RuntimeGameSettingsPatch +): Promise<{ requestId: string; result?: TurnDaemonCommandResult; failed?: string }> => { + const requestId = `gateway-runtime:${actionId}`; + const command: TurnDaemonCommand = { + type: 'updateRuntimeSettings', + requestId, + actionId, + settings, + }; + 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 || + JSON.stringify(payload.settings) !== JSON.stringify(settings) + ) { + 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 }; +}; + +const reprojectTournamentClock = async ( + redis: RuntimeSettingsRedisClient, + profileName: string, + actionId: string, + result: Extract +): Promise => { + const stateKey = `sammo:${profileName}:tournament:state`; + const sourceKeys = { + stateKey, + sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, + sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, + realtimeEventChannel: buildGameEventChannel(profileName), + }; + 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.runtimeSettingsActionIds) + ? state.runtimeSettingsActionIds.filter((entry): entry is string => typeof entry === 'string') + : []; + if (applied.includes(actionId)) return true; + const previousClock = new GameClock({ + baseTime: new Date(result.previousClockBaseTime), + tick: 0, + mode: 'manual', + wallAnchor: new Date(result.previousClockBaseTime), + turnSeconds: result.previousTurnTermMinutes * 60, + }); + const nextClock = new GameClock({ + baseTime: new Date(result.clockBaseTime), + tick: 0, + mode: 'manual', + wallAnchor: new Date(result.clockBaseTime), + turnSeconds: result.turnTermMinutes * 60, + }); + const reproject = (value: string | undefined): string | undefined => { + if (!value) return value; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return nextClock.tickToDate(previousClock.dateToTick(parsed)).toISOString(); + }; + const nextState: TournamentClockState = { + ...state, + nextAt: reproject(state.nextAt), + bettingCloseAt: reproject(state.bettingCloseAt), + runtimeSettingsActionIds: [...applied, actionId], + }; + await writeTournamentProjection(redis, sourceKeys, [{ key: stateKey, value: nextState }]); + return true; + } finally { + if ((await redis.get(lockKey)) === token) await redis.del(lockKey); + } + } + await sleep(10); + } + throw new Error('토너먼트 턴 간격 변경 lock을 획득하지 못했습니다.'); +}; + +export const applyRuntimeGameSettings = async (options: { + action: GatewayAdminActionRecord; + profileName: string; + db: GamePrismaClient; + redis?: RuntimeSettingsRedisClient; +}): Promise => { + const { action, profileName, db, redis } = options; + if (!action.id) return { status: 'FAILED', detail: '런타임 설정 action ID가 없습니다.' }; + const settings = normalizeSettings(action.payload?.settings); + if (!settings) return { status: 'FAILED', detail: '런타임 설정 payload가 올바르지 않습니다.' }; + const engine = await ensureEngineCommand(db, action.id, settings); + if (engine.failed) return { status: 'FAILED', detail: engine.failed }; + if (!engine.result) return { status: 'REQUESTED', detail: `게임 엔진 처리 대기 중: ${engine.requestId}` }; + if (engine.result.type !== 'updateRuntimeSettings' || !engine.result.ok) { + return { + status: 'FAILED', + detail: + engine.result.type === 'updateRuntimeSettings' + ? engine.result.reason + : '게임 엔진이 다른 결과를 반환했습니다.', + }; + } + if (engine.result.termChanged && !redis) { + return { + status: 'PARTIAL', + detail: `DB 설정은 적용됐지만 Redis 토너먼트 시각 동기화를 기다리는 중입니다: ${engine.requestId}`, + }; + } + const tournamentReprojected = + engine.result.termChanged && redis + ? await reprojectTournamentClock(redis, profileName, action.id, engine.result) + : false; + const summary = [ + `턴 ${engine.result.turnTermMinutes}분`, + settings.blockGeneralCreate === undefined ? null : `장수 생성 ${settings.blockGeneralCreate}`, + settings.autorunUser === undefined + ? null + : settings.autorunUser === null + ? '유저 자동턴 끔' + : `유저 자동턴 ${settings.autorunUser.limitMinutes}분`, + engine.result.termChanged ? `장수 ${engine.result.shiftedGenerals}명 시각 보정` : null, + tournamentReprojected ? '토너먼트 시각 보정' : null, + ].filter((entry): entry is string => entry !== null); + return { status: 'APPLIED', detail: summary.join(' · ') }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index ae30b141..ada29801 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -89,6 +89,7 @@ import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; import { EngineStateManager } from './engineStateManager.js'; import { applyRuntimeClockShift } from './runtimeClockShift.js'; +import { applyRuntimeGameSettings } from './runtimeGameSettings.js'; export interface TurnDaemonRuntimeOptions { profile: string; @@ -442,7 +443,7 @@ const createMonthlyCalendarRuntime = async (options: { profileName: options.profileName, getWorld: options.getWorld, getRedisClient: options.getRedisClient, - getWorldConfig: () => options.snapshot.worldConfig ?? null, + getWorldConfig: () => options.getWorld()?.getWorldConfig() ?? options.snapshot.worldConfig ?? null, getNationPowerRollCount: () => cache.nationPowerRollCount, getTournamentRollConsumed: () => cache.tournamentRollConsumed, now: () => options.getWorld()?.getGameNow(new Date(options.clock.nowMs())) ?? new Date(options.clock.nowMs()), @@ -451,7 +452,7 @@ const createMonthlyCalendarRuntime = async (options: { profileName: options.profileName, getWorld: options.getWorld, getRedisClient: options.getRedisClient, - getWorldConfig: () => options.snapshot.worldConfig ?? null, + getWorldConfig: () => options.getWorld()?.getWorldConfig() ?? options.snapshot.worldConfig ?? null, getNationPowerRollCount: () => cache.nationPowerRollCount, onTournamentRollConsumed: (consumed) => { cache.tournamentRollConsumed = consumed; @@ -599,6 +600,17 @@ const createStartedAdminActionConsumer = async (options: { redis: options.redisConnector?.client, }); } + if (action.action === 'UPDATE_RUNTIME_SETTINGS') { + if (!options.commandConnector) { + return { status: 'FAILED', detail: '게임 command database 연결이 없습니다.' }; + } + return applyRuntimeGameSettings({ + action, + profileName, + db: options.commandConnector.prisma, + redis: options.redisConnector?.client, + }); + } switch (action.action) { case 'RESUME': options.controlQueue.enqueue({ type: 'resume', reason }); @@ -766,7 +778,6 @@ const createTurnDaemonRuntimeWithLease = async ( const stateStore = new InMemoryTurnStateStore(world); let fastForwardPreparedMonth = ''; const processor = new InMemoryTurnProcessor(world, { - tickMinutes, beforeExecuteGeneral: reservedTurnStoreHandle ? async (general) => { if (options.exclusiveFastForward) { @@ -959,7 +970,8 @@ const createTurnDaemonRuntimeWithLease = async ( { clock, controlQueue: resolvedControlQueue, - getNextTickTime: (lastTurnTime) => getNextTickTime(lastTurnTime, tickMinutes), + getNextTickTime: (lastTurnTime) => + getNextTickTime(lastTurnTime, Math.max(1, Math.round(world.getState().tickSeconds / 60))), stateStore, processor, hooks, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index dae0c199..cf45cfc1 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -5,7 +5,14 @@ import type { TurnDaemonCommandResult, } from '../lifecycle/types.js'; import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra'; -import { asRecord, isCanonicalIsoTimestamp, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { + asRecord, + GAME_TICKS_PER_TURN, + isCanonicalIsoTimestamp, + JosaUtil, + LiteHashDRBG, + RandUtil, +} from '@sammo-ts/common'; import { LogCategory, LogFormat, @@ -921,6 +928,130 @@ async function handleShiftSchedule( }; } +async function handleUpdateRuntimeSettings( + ctx: CommandHandlerContext, + command: Extract +): Promise { + if (!ctx.commandDb) { + return { + type: 'updateRuntimeSettings', + ok: false, + actionId: command.actionId, + reason: '런타임 설정 변경은 데이터베이스 transaction 경계에서만 실행할 수 있습니다.', + }; + } + + const operationalAcceptedAt = await resolveOperationalAcceptedAt(ctx.commandDb, command); + if (command.settings.blockGeneralCreate !== undefined) { + ctx.world.updateWorldConfig({ blockGeneralCreate: command.settings.blockGeneralCreate }); + } + if (command.settings.autorunUser !== undefined) { + ctx.world.updateWorldMeta({ + autorun_user: + command.settings.autorunUser === null + ? null + : { + limit_minutes: command.settings.autorunUser.limitMinutes, + options: Object.fromEntries( + command.settings.autorunUser.options.map((option) => [option, true]) + ), + }, + }); + } + + const currentState = ctx.world.getState(); + const currentTermMinutes = Math.max(1, Math.round(currentState.tickSeconds / 60)); + const term = + command.settings.turnTermMinutes === undefined + ? { + changed: false, + previousTurnTermMinutes: currentTermMinutes, + turnTermMinutes: currentTermMinutes, + previousClockBaseTime: (currentState.clockBaseTime ?? currentState.lastTurnTime).toISOString(), + clockBaseTime: (currentState.clockBaseTime ?? currentState.lastTurnTime).toISOString(), + shiftedGenerals: 0, + lastTurnTime: currentState.lastTurnTime.toISOString(), + } + : ctx.world.changeTurnTerm(command.settings.turnTermMinutes, operationalAcceptedAt); + + let reprojectedAuctions = 0; + let reprojectedMessages = 0; + let reprojectedVotes = 0; + if (term.changed) { + const ticksPerSecond = BigInt(GAME_TICKS_PER_TURN / (term.turnTermMinutes * 60)); + const baseTime = new Date(term.clockBaseTime); + reprojectedAuctions = await ctx.commandDb.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET close_at = CAST(${baseTime} AS timestamp) + + (close_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((close_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond', + updated_at = NOW() + WHERE close_tick IS NOT NULL + ` + ); + reprojectedMessages = await ctx.commandDb.$executeRaw( + GamePrisma.sql` + UPDATE message + SET time = CASE + WHEN time_tick IS NULL THEN time + ELSE CAST(${baseTime} AS timestamp) + + (time_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((time_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond' + END, + valid_until = CASE + WHEN valid_until_tick IS NULL THEN valid_until + ELSE CAST(${baseTime} AS timestamp) + + (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond' + END + WHERE time_tick IS NOT NULL OR valid_until_tick IS NOT NULL + ` + ); + reprojectedVotes = await ctx.commandDb.$executeRaw( + GamePrisma.sql` + UPDATE vote_poll + SET start_at = CASE + WHEN start_tick IS NULL THEN start_at + ELSE CAST(${baseTime} AS timestamp) + + (start_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((start_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond' + END, + end_at = CASE + WHEN end_tick IS NULL THEN end_at + ELSE CAST(${baseTime} AS timestamp) + + (end_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((end_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond' + END + WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL + ` + ); + ctx.world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `★턴시간이 ${term.turnTermMinutes}분으로 변경됩니다.`, + }); + } + + return { + type: 'updateRuntimeSettings', + ok: true, + actionId: command.actionId, + settings: command.settings, + termChanged: term.changed, + previousTurnTermMinutes: term.previousTurnTermMinutes, + turnTermMinutes: term.turnTermMinutes, + previousClockBaseTime: term.previousClockBaseTime, + clockBaseTime: term.clockBaseTime, + lastTurnTime: term.lastTurnTime, + shiftedGenerals: term.shiftedGenerals, + reprojectedAuctions, + reprojectedMessages, + reprojectedVotes, + checkpoint: ctx.world.getCheckpoint(), + }; +} + async function handleTroopJoin( ctx: CommandHandlerContext, command: Extract @@ -2505,6 +2636,8 @@ export const createTurnDaemonCommandHandler = (options: { handleSelectPoolReselect(ctx, command as Extract), shiftSchedule: (command) => handleShiftSchedule(ctx, command as Extract), + updateRuntimeSettings: (command) => + handleUpdateRuntimeSettings(ctx, command as Extract), }; return { diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index eb8f98e5..a140393e 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -127,6 +127,7 @@ describe('durable read-model change journal mapping', () => { const world = { getState: () => ({ ...state, meta: { ...state.meta } }), getScenarioConfig: () => config, + getWorldConfig: () => config, } as unknown as InMemoryTurnWorld; const baseline = createWorldReadModelSignature(world); diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index b4b24682..a5a32343 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -3,6 +3,8 @@ 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 { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const buildGeneral = (id: number, turnTime: string): TurnGeneral => @@ -160,6 +162,107 @@ describe('runtime clock shift', () => { }); }); +describe('runtime turn term change', () => { + it('preserves the current game display while reprojecting tick-owned dates', () => { + const wallAnchor = new Date('2026-07-30T10:00:00.000Z'); + const changedAt = new Date('2026-07-30T10:05:00.000Z'); + const world = buildWorld({ + clockBaseTime: wallAnchor, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: wallAnchor, + lastTurnTick: 0, + }); + world.setCheckpoint({ + turnTime: '2026-07-30T10:10:00.000Z', + turnTick: 36_000_000, + generalId: 1, + year: 190, + month: 1, + }); + + const before = world.getGameNow(changedAt); + const result = world.changeTurnTerm(20, changedAt); + + expect(result).toMatchObject({ + changed: true, + previousTurnTermMinutes: 10, + turnTermMinutes: 20, + previousClockBaseTime: '2026-07-30T10:00:00.000Z', + clockBaseTime: '2026-07-30T09:55:00.000Z', + lastTurnTime: '2026-07-30T09:55:00.000Z', + shiftedGenerals: 2, + }); + expect(world.getGameNow(changedAt)).toEqual(before); + expect(world.getGeneralById(1)?.turnTime.toISOString()).toBe('2026-07-30T10:15:00.000Z'); + expect(world.getGeneralById(1)?.turnTick).toBe(36_000_000); + expect(world.getCheckpoint()?.turnTime).toBe('2026-07-30T10:15:00.000Z'); + expect(world.getState()).toMatchObject({ + tickSeconds: 1200, + clockTick: 18_000_000, + lastTurnTick: 0, + }); + expect(world.getWorldConfig()).toMatchObject({ turnTermMinutes: 20 }); + }); + + it('updates all three live settings and only emits a new history log', async () => { + const changedAt = new Date('2026-07-30T10:05:00.000Z'); + const world = buildWorld({ + clockBaseTime: new Date('2026-07-30T10:00:00.000Z'), + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-07-30T10:00:00.000Z'), + lastTurnTick: 0, + }); + const executeRaw = vi.fn(async () => 1); + const db = { + inputEvent: { + findUnique: vi.fn(async () => ({ + createdAt: changedAt, + target: 'ENGINE', + eventType: 'updateRuntimeSettings', + })), + }, + $executeRaw: executeRaw, + } as unknown as GamePrismaClient; + const handler = createTurnDaemonCommandHandler({ world }); + + const result = await handler.handle( + { + type: 'updateRuntimeSettings', + requestId: 'runtime-settings:test', + actionId: 'action-test', + settings: { + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] }, + }, + }, + { db } + ); + + expect(result).toMatchObject({ + type: 'updateRuntimeSettings', + ok: true, + termChanged: true, + reprojectedAuctions: 1, + reprojectedMessages: 1, + reprojectedVotes: 1, + }); + expect(executeRaw).toHaveBeenCalledTimes(3); + expect(world.getWorldConfig()).toMatchObject({ turnTermMinutes: 20, blockGeneralCreate: 2 }); + expect(world.getState().meta).toMatchObject({ + autorun_user: { + limit_minutes: 720, + options: { develop: true, recruit_high: true, chief: true }, + }, + }); + expect(world.peekDirtyState().logs).toEqual([ + expect.objectContaining({ text: '★턴시간이 20분으로 변경됩니다.' }), + ]); + }); +}); + 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'; @@ -285,3 +388,126 @@ describe('runtime clock shift projection', () => { expect(inputEventCreate).toHaveBeenCalledTimes(3); }); }); + +describe('runtime game settings projection', () => { + it('waits for the engine result and reprojects tournament tick dates idempotently', async () => { + const actionId = '98f1f0e4-3b95-4aeb-9925-c7e93caf1ba7'; + let eventStatus: 'PENDING' | 'SUCCEEDED' = 'PENDING'; + let created = false; + const db = { + inputEvent: { + create: vi.fn(async () => { + if (created) throw { code: 'P2002' }; + created = true; + return {}; + }), + findUniqueOrThrow: vi.fn(async () => + eventStatus === 'PENDING' + ? { + eventType: 'updateRuntimeSettings', + payload: { + type: 'updateRuntimeSettings', + actionId, + settings: { + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { limitMinutes: 720, options: ['develop', 'chief'] }, + }, + }, + status: 'PENDING', + result: null, + error: null, + } + : { + eventType: 'updateRuntimeSettings', + payload: { + type: 'updateRuntimeSettings', + actionId, + settings: { + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { limitMinutes: 720, options: ['develop', 'chief'] }, + }, + }, + status: 'SUCCEEDED', + result: { + type: 'updateRuntimeSettings', + ok: true, + actionId, + settings: { + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { limitMinutes: 720, options: ['develop', 'chief'] }, + }, + termChanged: true, + previousTurnTermMinutes: 10, + turnTermMinutes: 20, + previousClockBaseTime: '2026-07-30T10:00:00.000Z', + clockBaseTime: '2026-07-30T09:55:00.000Z', + lastTurnTime: '2026-07-30T09:55:00.000Z', + shiftedGenerals: 2, + reprojectedAuctions: 1, + reprojectedMessages: 1, + reprojectedVotes: 1, + }, + error: null, + } + ), + }, + } as unknown as GamePrismaClient; + const stateKey = 'sammo:hwe:default:tournament:state'; + const values = new Map([ + [ + stateKey, + JSON.stringify({ + stage: 1, + nextAt: '2026-07-30T10:10:00.000Z', + bettingCloseAt: '2026-07-30T10:05:00.000Z', + }), + ], + ]); + const redis = { + get: async (key: string) => values.get(key) ?? null, + set: async (key: string, value: string, options?: { NX?: boolean }) => { + if (options?.NX && values.has(key)) return null; + values.set(key, value); + return 'OK'; + }, + del: async (key: string) => (values.delete(key) ? 1 : 0), + eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => { + options.arguments.forEach((value, index) => values.set(options.keys[index]!, value)); + return '1'; + }, + }; + const action = { + id: actionId, + profileName: 'hwe:default', + action: 'UPDATE_RUNTIME_SETTINGS', + durationMinutes: null, + payload: { + settings: { + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { limitMinutes: 720, options: ['develop', 'chief'] }, + }, + }, + }; + + await expect( + applyRuntimeGameSettings({ action, profileName: 'hwe:default', db, redis }) + ).resolves.toMatchObject({ status: 'REQUESTED' }); + eventStatus = 'SUCCEEDED'; + await expect( + applyRuntimeGameSettings({ action, profileName: 'hwe:default', db, redis }) + ).resolves.toMatchObject({ status: 'APPLIED' }); + await expect( + applyRuntimeGameSettings({ action, profileName: 'hwe:default', db, redis }) + ).resolves.toMatchObject({ status: 'APPLIED' }); + + expect(JSON.parse(values.get(stateKey) ?? '{}')).toMatchObject({ + nextAt: '2026-07-30T10:15:00.000Z', + bettingCloseAt: '2026-07-30T10:05:00.000Z', + runtimeSettingsActionIds: [actionId], + }); + }); +}); diff --git a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts index bb5f0f1b..4c51137d 100644 --- a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts +++ b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts @@ -17,7 +17,10 @@ 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 runtimeSettingsRequestId = 'integration:engine:runtime-game-settings'; +const runtimeSettingsActionId = 'c9f68480-dba9-4e03-a62b-499e6234f18a'; +const generalIds = [990_301, 990_302, 990_303] as const; +const runtimeSettingsLogText = 'runtime-settings-existing-log'; const buildGeneral = (id: number, turnTime: Date): TurnGeneral => ({ @@ -50,16 +53,23 @@ const buildGeneral = (id: number, turnTime: Date): TurnGeneral => npcState: 0, }) as TurnGeneral; -const waitForSucceeded = async (db: GamePrismaClient): Promise => { +const waitForSucceeded = async (db: GamePrismaClient, targetRequestId = requestId): Promise => { const deadline = Date.now() + 5_000; while (Date.now() < deadline) { - const event = await db.inputEvent.findUnique({ where: { requestId }, select: { status: true } }); + const event = await db.inputEvent.findUnique({ + where: { requestId: targetRequestId }, + 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'); + const event = await db.inputEvent.findUnique({ + where: { requestId: targetRequestId }, + select: { status: true, error: true, attempts: true }, + }); + throw new Error(`runtime input event did not complete: ${JSON.stringify(event)}`); }; integration('runtime clock shift persistence', () => { @@ -71,17 +81,27 @@ integration('runtime clock shift persistence', () => { await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); - await db.inputEvent.deleteMany({ where: { requestId } }); + await db.inputEvent.deleteMany({ where: { requestId: { in: [requestId, runtimeSettingsRequestId] } } }); + await db.votePoll.deleteMany({ where: { openerGeneralId: generalIds[2] } }); + await db.message.deleteMany({ where: { mailbox: generalIds[2] } }); + await db.logEntry.deleteMany({ where: { text: runtimeSettingsLogText } }); await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); - await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } }); + await db.worldState.deleteMany({ + where: { scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings'] } }, + }); }); afterAll(async () => { - await db.inputEvent.deleteMany({ where: { requestId } }); + await db.inputEvent.deleteMany({ where: { requestId: { in: [requestId, runtimeSettingsRequestId] } } }); + await db.votePoll.deleteMany({ where: { openerGeneralId: generalIds[2] } }); + await db.message.deleteMany({ where: { mailbox: generalIds[2] } }); + await db.logEntry.deleteMany({ where: { text: runtimeSettingsLogText } }); await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); - await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } }); + await db.worldState.deleteMany({ + where: { scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings'] } }, + }); await closeDb?.(); }); @@ -126,7 +146,7 @@ integration('runtime clock shift persistence', () => { db.auction.create({ data: { type: 'BUY_RICE', - hostGeneralId: generalIds[index % generalIds.length]!, + hostGeneralId: generals[index % generals.length]!.id, detail: {}, status, closeAt: new Date(`2099-07-30T1${index}:00:00.000Z`), @@ -278,4 +298,250 @@ integration('runtime clock shift persistence', () => { await db.worldState.delete({ where: { id: row.id } }); }); + + it('reprojects tick-owned dates for a live turn-term change without rewriting existing log timestamps', async () => { + const base = new Date('2099-08-01T10:00:00.000Z'); + const row = await db.worldState.create({ + data: { + scenarioCode: 'runtime-game-settings', + currentYear: 191, + currentMonth: 2, + tickSeconds: 600, + clockBaseTime: base, + clockTick: 0, + clockMode: 'manual', + clockWallAnchor: base, + lastTurnTick: 0, + config: { turnTermMinutes: 10, blockGeneralCreate: 0 }, + meta: { lastTurnTime: base.toISOString(), turnterm: 10 }, + }, + }); + const general = { + ...buildGeneral(generalIds[2], new Date('2099-08-01T10:10:00.000Z')), + turnTick: GAME_TICKS_PER_TURN, + recentWarTime: new Date('2099-08-01T10:05:00.000Z'), + recentWarTick: GAME_TICKS_PER_TURN / 2, + }; + await db.general.create({ + data: { + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + turnTime: general.turnTime, + turnTick: BigInt(general.turnTick), + recentWarTime: general.recentWarTime, + recentWarTick: BigInt(general.recentWarTick), + }, + }); + const auction = await db.auction.create({ + data: { + type: 'BUY_RICE', + hostGeneralId: general.id, + detail: {}, + status: 'OPEN', + closeAt: new Date('2099-08-01T10:10:00.000Z'), + closeTick: BigInt(GAME_TICKS_PER_TURN), + }, + }); + const message = await db.message.create({ + data: { + mailbox: general.id, + type: 'runtime-settings-test', + src: 0, + dest: general.id, + time: new Date('2099-08-01T10:05:00.000Z'), + timeTick: BigInt(GAME_TICKS_PER_TURN / 2), + validUntil: new Date('2099-08-01T10:10:00.000Z'), + validUntilTick: BigInt(GAME_TICKS_PER_TURN), + message: {}, + }, + }); + const vote = await db.votePoll.create({ + data: { + title: 'runtime settings test', + options: ['yes', 'no'], + revealMode: 'ALWAYS', + openerGeneralId: general.id, + openerName: general.name, + startAt: new Date('2099-08-01T10:05:00.000Z'), + startTick: BigInt(GAME_TICKS_PER_TURN / 2), + endAt: new Date('2099-08-01T10:10:00.000Z'), + endTick: BigInt(GAME_TICKS_PER_TURN), + }, + }); + const originalLogTime = new Date('2026-01-02T03:04:05.000Z'); + const existingLog = await db.logEntry.create({ + data: { + scope: 'SYSTEM', + category: 'HISTORY', + year: 191, + month: 2, + text: runtimeSettingsLogText, + createdAt: originalLogTime, + }, + }); + + const state: TurnWorldState = { + id: row.id, + currentYear: 191, + currentMonth: 2, + tickSeconds: 600, + lastTurnTime: base, + clockBaseTime: base, + clockTick: 0, + clockMode: 'manual', + clockWallAnchor: base, + lastTurnTick: 0, + 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' }, + }, + worldConfig: row.config as Record, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [general], + 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: base.toISOString(), + turnTick: 0, + generalId: 0, + year: 191, + month: 2, + }); + 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, Math.max(1, Math.round(world.getState().tickSeconds / 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: runtimeSettingsRequestId, + target: 'ENGINE', + eventType: 'updateRuntimeSettings', + payload: { + type: 'updateRuntimeSettings', + requestId: runtimeSettingsRequestId, + actionId: runtimeSettingsActionId, + settings: { + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { + limitMinutes: 720, + options: ['develop', 'recruit_high', 'chief'], + }, + }, + } as GamePrisma.InputJsonValue, + }, + }); + const loop = lifecycle.start(); + try { + await waitForSucceeded(db, runtimeSettingsRequestId); + } finally { + await lifecycle.stop('test complete'); + await loop; + await hooks.close(); + } + + const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } }); + expect(storedWorld).toMatchObject({ tickSeconds: 1200, clockBaseTime: base, lastTurnTick: 0n }); + expect(storedWorld.config).toMatchObject({ turnTermMinutes: 20, blockGeneralCreate: 2 }); + expect(storedWorld.meta).toMatchObject({ + turnterm: 20, + autorun_user: { + limit_minutes: 720, + options: { develop: true, recruit_high: true, chief: true }, + }, + }); + expect(await db.general.findUniqueOrThrow({ where: { id: general.id } })).toMatchObject({ + turnTime: new Date('2099-08-01T10:20:00.000Z'), + turnTick: BigInt(GAME_TICKS_PER_TURN), + recentWarTime: new Date('2099-08-01T10:10:00.000Z'), + }); + expect((await db.auction.findUniqueOrThrow({ where: { id: auction.id } })).closeAt).toEqual( + new Date('2099-08-01T10:20:00.000Z') + ); + expect(await db.message.findUniqueOrThrow({ where: { id: message.id } })).toMatchObject({ + time: new Date('2099-08-01T10:10:00.000Z'), + validUntil: new Date('2099-08-01T10:20:00.000Z'), + }); + expect(await db.votePoll.findUniqueOrThrow({ where: { id: vote.id } })).toMatchObject({ + startAt: new Date('2099-08-01T10:10:00.000Z'), + endAt: new Date('2099-08-01T10:20:00.000Z'), + }); + expect(await db.logEntry.findUniqueOrThrow({ where: { id: existingLog.id } })).toMatchObject({ + text: runtimeSettingsLogText, + createdAt: originalLogTime, + }); + expect(await db.logEntry.findFirst({ where: { text: { contains: '턴시간이 20분' } } })).not.toBeNull(); + expect(lifecycle.getStatus().nextTurnTime).toBe('2099-08-01T10:20:00.000Z'); + expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: runtimeSettingsRequestId } })).toMatchObject( + { + status: 'SUCCEEDED', + result: { + type: 'updateRuntimeSettings', + ok: true, + actionId: runtimeSettingsActionId, + termChanged: true, + previousTurnTermMinutes: 10, + turnTermMinutes: 20, + shiftedGenerals: 1, + reprojectedAuctions: 1, + reprojectedMessages: 1, + reprojectedVotes: 1, + }, + } + ); + }); }); diff --git a/app/gateway-api/src/adminCapabilities.ts b/app/gateway-api/src/adminCapabilities.ts index 3f6155cf..154d2293 100644 --- a/app/gateway-api/src/adminCapabilities.ts +++ b/app/gateway-api/src/adminCapabilities.ts @@ -117,6 +117,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown): const action = (rawInput as { action?: unknown }).action; if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule'; if (action === 'RESUME') return 'admin.resume.when-stopped'; + if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime'; if (action === 'OPEN_SURVEY') return 'admin.survey.open'; return 'admin.profiles.runtime'; } diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index a55df86b..9c1aaec4 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -41,6 +41,7 @@ const zServerAction = z.enum([ 'STOP', 'ACCELERATE', 'DELAY', + 'UPDATE_RUNTIME_SETTINGS', 'RESET_NOW', 'RESET_SCHEDULED', 'OPEN_SURVEY', @@ -428,6 +429,30 @@ const zInstallAutorun = z.object({ }); const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value); + +const zRuntimeSettings = z + .object({ + turnTermMinutes: z + .number() + .int() + .refine((value) => isAllowedTurnTerm(value), { + message: 'turnTermMinutes must divide 120.', + }) + .optional(), + blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), + autorunUser: z + .object({ + limitMinutes: z.number().int().min(1).max(43200), + options: z.array(z.enum(AUTORUN_USER_OPTIONS)).min(1), + }) + .nullable() + .optional(), + }) + .strict() + .refine((settings) => Object.values(settings).some((value) => value !== undefined), { + message: 'At least one runtime setting is required.', + }); + const isUniqueConstraintError = (error: unknown): boolean => Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002'); @@ -1580,7 +1605,7 @@ export const adminRouter = router({ canReadProfile(adminAuth, profile.profileName) ); const profileNames = profiles.map((profile) => profile.profileName); - const [runtimeActions, activeOperations] = await Promise.all([ + const [runtimeActions, activeOperations, runtimeSettings] = await Promise.all([ ctx.prisma.gatewayRuntimeAction.findMany({ where: { profileName: { in: profileNames } }, orderBy: { createdAt: 'desc' }, @@ -1592,6 +1617,7 @@ export const adminRouter = router({ }, select: { id: true, profileName: true, status: true }, }), + ctx.orchestrator.listRuntimeSettings?.(profileNames) ?? Promise.resolve([]), ]); const activeOperationByProfile = new Map( activeOperations.map((operation) => [operation.profileName, operation]) @@ -1608,9 +1634,11 @@ export const adminRouter = router({ profiles.map((profile) => profile.profileName) ); const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state])); + const runtimeSettingsMap = new Map(runtimeSettings.map((settings) => [settings.profileName, settings])); return profiles.map((profile) => ({ ...profile, runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [], + runtimeSettings: runtimeSettingsMap.get(profile.profileName) ?? null, activeOperation: activeOperationByProfile.get(profile.profileName) ?? null, runtime: runtimeMap.get(profile.profileName) ?? { profileName: profile.profileName, @@ -2006,6 +2034,7 @@ export const adminRouter = router({ profileName: z.string().min(1), action: zServerAction, durationMinutes: z.number().int().min(1).max(1440).optional(), + runtimeSettings: zRuntimeSettings.optional(), scheduledAt: z.string().datetime().optional(), reason: z.string().max(200).optional(), }) @@ -2024,6 +2053,19 @@ export const adminRouter = router({ message: 'durationMinutes is required for acceleration or delay.', }); } + if (input.action === 'UPDATE_RUNTIME_SETTINGS') { + if (!input.runtimeSettings) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'runtimeSettings is required.' }); + } + if (!input.reason || input.reason.trim().length < 3) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '변경 사유를 입력해 주세요.' }); + } + } else if (input.runtimeSettings) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'runtimeSettings is not valid for this action.', + }); + } if (input.scheduledAt) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -2084,6 +2126,19 @@ export const adminRouter = router({ message: 'Survey permission is required.', }); } + } else if (input.action === 'UPDATE_RUNTIME_SETTINGS') { + if (!gatewayProfileCapabilities(profile.status).runtimeExpected) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '실행 중인 프로필에서만 현재 기수 설정을 바꿀 수 있습니다.', + }); + } + if (!canManageProfiles) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Profile management permission is required.', + }); + } } else if (!canManageProfiles) { throw new TRPCError({ code: 'FORBIDDEN', @@ -2098,12 +2153,17 @@ export const adminRouter = router({ }); } - if (input.action === 'ACCELERATE' || input.action === 'DELAY') { + if ( + input.action === 'ACCELERATE' || + input.action === 'DELAY' || + input.action === 'UPDATE_RUNTIME_SETTINGS' + ) { try { const runtimeAction = await ctx.prisma.gatewayRuntimeAction.create({ data: { profileName: input.profileName, action: input.action, + payload: input.runtimeSettings ? { settings: input.runtimeSettings } : {}, durationMinutes: input.durationMinutes, reason: input.reason, requestedBy: adminAuth.user.id, @@ -2116,7 +2176,7 @@ export const adminRouter = router({ } throw new TRPCError({ code: 'CONFLICT', - message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.', + message: '이 프로필의 이전 런타임 변경 요청이 아직 처리 중입니다.', }); } } diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 1b247375..5a87f309 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -70,6 +70,23 @@ export interface ProfileRuntimeSnapshot extends ProfileRuntimeState { profileName: string; } +export interface ProfileRuntimeSettingsSnapshot { + profileName: string; + turnTermMinutes: number; + blockGeneralCreate: 0 | 1 | 2; + autorunUser: { + limitMinutes: number; + options: Array<'develop' | 'warp' | 'recruit' | 'recruit_high' | 'train' | 'battle' | 'chief'>; + } | null; +} + +type RuntimeAutorunOption = + NonNullable extends { + options: Array; + } + ? Option + : never; + export interface GatewayOrchestratorHandle { start(): void; stop(): Promise; @@ -82,6 +99,7 @@ export interface GatewayOrchestratorHandle { skipped: string[]; }>; listRuntimeStates(profileNames: string[]): Promise; + listRuntimeSettings?(profileNames: string[]): Promise; } const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu; @@ -706,6 +724,67 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return mapRuntimeStates(profileNames, processStates); } + async listRuntimeSettings(profileNames: string[]): Promise { + const allowedAutorunOptions = new Set([ + 'develop', + 'warp', + 'recruit', + 'recruit_high', + 'train', + 'battle', + 'chief', + ] as const); + const snapshots = await Promise.all( + profileNames.map(async (profileName): Promise => { + const profile = await this.repository.getProfile(profileName); + if (!profile || profile.currentScenario === null) return null; + const connector = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) }); + try { + await connector.connect(); + const row = await connector.prisma.worldState.findFirst({ + select: { tickSeconds: true, config: true, meta: true }, + }); + if (!row) return null; + const config = isRecord(row.config) ? row.config : {}; + const meta = isRecord(row.meta) ? row.meta : {}; + const rawBlock = Number(config.blockGeneralCreate ?? 0); + const blockGeneralCreate = ([0, 1, 2].includes(rawBlock) ? rawBlock : 0) as 0 | 1 | 2; + const rawAutorun = isRecord(meta.autorun_user) ? meta.autorun_user : null; + const limitMinutes = rawAutorun ? Number(rawAutorun.limit_minutes ?? 0) : 0; + const rawOptions = rawAutorun + ? Array.isArray(rawAutorun.options) + ? rawAutorun.options + : isRecord(rawAutorun.options) + ? Object.entries(rawAutorun.options) + .filter(([, enabled]) => enabled === true) + .map(([option]) => option) + : [] + : []; + const autorunOptions = rawOptions.filter( + ( + option + ): option is 'develop' | 'warp' | 'recruit' | 'recruit_high' | 'train' | 'battle' | 'chief' => + typeof option === 'string' && allowedAutorunOptions.has(option as RuntimeAutorunOption) + ); + return { + profileName, + turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)), + blockGeneralCreate, + autorunUser: + Number.isInteger(limitMinutes) && limitMinutes > 0 && autorunOptions.length > 0 + ? { limitMinutes, options: autorunOptions } + : null, + }; + } catch { + return null; + } finally { + await connector.disconnect().catch(() => undefined); + } + }) + ); + return snapshots.filter((snapshot): snapshot is ProfileRuntimeSettingsSnapshot => snapshot !== null); + } + async reconcileNow(): Promise { if (this.stopping || this.reconcileInFlight) { return; diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 528243b2..82395628 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -287,6 +287,14 @@ const buildCaller = async ( } }, cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }), + listRuntimeSettings: async () => [ + { + profileName: 'che:2', + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] }, + }, + ], listRuntimeStates: async () => { runtimeStateListCount += 1; return []; @@ -299,6 +307,7 @@ const buildCaller = async ( findFirst: async () => ({ id: options.firstUserIsAdmin === false ? 'bootstrap-user' : admin.id }), }, gatewayRuntimeAction: { + findMany: async () => [], create: async ({ data }: { data: Record }) => { if (options.runtimeActionCreateError) { throw options.runtimeActionCreateError; @@ -317,6 +326,9 @@ const buildCaller = async ( }; }, }, + gatewayOperation: { + findMany: async () => [], + }, systemSetting: { findUnique: async () => ({ id: 1, notice: storedNotice }), upsert: async ({ create, update }: { create: { notice: string }; update: { notice: string } }) => { @@ -371,6 +383,32 @@ describe('admin profile navigation API', () => { ]); expect(harness.getRuntimeStateListCount()).toBe(0); }); + + it('returns live settings from the profile database separately from reset defaults', async () => { + const harness = await buildCaller( + async () => { + throw new Error('not used'); + }, + { + profileMeta: { + resetDefaults: { + turnTermMinutes: 60, + blockGeneralCreate: 0, + autorunUser: null, + }, + }, + } + ); + + const result = await harness.caller.admin.profiles.list(); + + expect(result[0]?.runtimeSettings).toEqual({ + profileName: 'che:2', + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] }, + }); + }); }); describe('admin scenario catalog API', () => { @@ -1133,6 +1171,7 @@ describe('admin runtime clock action API', () => { { profileName: 'che:2', action: 'ACCELERATE', + payload: {}, durationMinutes: 15, reason: '운영 일정 조정', requestedBy: harness.admin.id, @@ -1153,10 +1192,78 @@ describe('admin runtime clock action API', () => { }) ).rejects.toMatchObject({ code: 'CONFLICT', - message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.', + message: '이 프로필의 이전 런타임 변경 요청이 아직 처리 중입니다.', }); }); + it('queues all live game settings as one durable runtime action', async () => { + const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'RUNNING' }); + + const result = await harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'UPDATE_RUNTIME_SETTINGS', + runtimeSettings: { + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { + limitMinutes: 720, + options: ['develop', 'recruit_high', 'chief'], + }, + }, + reason: '운영 중 규칙 변경', + }); + + expect(result).toMatchObject({ + ok: true, + action: { action: 'UPDATE_RUNTIME_SETTINGS', status: 'REQUESTED' }, + }); + expect(harness.createdRuntimeActions).toEqual([ + { + profileName: 'che:2', + action: 'UPDATE_RUNTIME_SETTINGS', + payload: { + settings: { + turnTermMinutes: 20, + blockGeneralCreate: 2, + autorunUser: { + limitMinutes: 720, + options: ['develop', 'recruit_high', 'chief'], + }, + }, + }, + durationMinutes: undefined, + reason: '운영 중 규칙 변경', + requestedBy: harness.admin.id, + }, + ]); + }); + + it('rejects a live game setting change without a reason or running database', async () => { + const running = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'RUNNING' }); + await expect( + running.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'UPDATE_RUNTIME_SETTINGS', + runtimeSettings: { turnTermMinutes: 20 }, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '변경 사유를 입력해 주세요.' }); + + const stopped = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'STOPPED' }); + await expect( + stopped.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'UPDATE_RUNTIME_SETTINGS', + runtimeSettings: { blockGeneralCreate: 1 }, + reason: '운영 정책 변경', + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '실행 중인 프로필에서만 현재 기수 설정을 바꿀 수 있습니다.', + }); + expect(running.createdRuntimeActions).toEqual([]); + expect(stopped.createdRuntimeActions).toEqual([]); + }); + it('rejects a scheduled clock shift instead of silently applying it immediately', async () => { const harness = await buildCaller(unusedCreateOperation); diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index e93dcfab..bd942605 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -8,8 +8,8 @@ const operationNames = (route: Route): string[] => { type RuntimeAction = { id: string; - action: 'ACCELERATE' | 'DELAY'; - durationMinutes: number; + action: 'ACCELERATE' | 'DELAY' | 'UPDATE_RUNTIME_SETTINGS'; + durationMinutes: number | null; status: 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED'; detail: string; handler: string | null; @@ -45,6 +45,7 @@ const installFixture = async ( let installRequested = false; let installActive = false; let postRequestProfileReads = 0; + let requestedRuntimeSettings = false; const requestBodies: unknown[] = []; let releaseRequest = (): void => {}; const requestGate = options.deferRequest @@ -66,6 +67,7 @@ const installFixture = async ( const operations = operationNames(route); if (operations.includes('admin.profiles.requestAction')) { requested = true; + requestedRuntimeSettings = JSON.stringify(body).includes('UPDATE_RUNTIME_SETTINGS'); requestBodies.push(body); await requestGate; } @@ -171,6 +173,20 @@ const installFixture = async ( status: options.profileStatus ?? 'RUNNING', buildStatus: 'SUCCEEDED', meta: {}, + runtimeSettings: requestedRuntimeSettings + ? { + turnTermMinutes: 20, + blockGeneralCreate: 1, + autorunUser: { + limitMinutes: 720, + options: ['develop', 'recruit_high', 'chief'], + }, + } + : { + turnTermMinutes: 10, + blockGeneralCreate: 2, + autorunUser: null, + }, activeOperation: installActive ? { id: '77777777-7777-4777-8777-777777777777', @@ -189,11 +205,20 @@ const installFixture = async ( runtimeActions: keepPending ? [runtimeAction('REQUESTED')] : requested - ? (options.afterRequestActions ?? [ - runtimeAction('APPLIED', { - detail: '15분 가속 · 장수 2명 · 경매 1건', - }), - ]) + ? (options.afterRequestActions ?? + (requestedRuntimeSettings + ? [ + runtimeAction('APPLIED', { + action: 'UPDATE_RUNTIME_SETTINGS', + durationMinutes: null, + detail: '턴 20분 · 장수 생성 불가 · 유저 자동턴 적용', + }), + ] + : [ + runtimeAction('APPLIED', { + detail: '15분 가속 · 장수 2명 · 경매 1건', + }), + ])) : (options.initialActions ?? []), }, ]); @@ -203,8 +228,8 @@ const installFixture = async ( ok: true, action: { id: '68f1f0e4-3b95-4aeb-9925-c7e93caf1ba7', - action: 'ACCELERATE', - durationMinutes: 15, + action: requestedRuntimeSettings ? 'UPDATE_RUNTIME_SETTINGS' : 'ACCELERATE', + durationMinutes: requestedRuntimeSettings ? null : 15, status: 'REQUESTED', detail: null, handler: null, @@ -284,8 +309,69 @@ test('reports clock-shift acceptance separately from actual application', async 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(); + await expect(page.getByRole('button', { name: '설문 오픈 (게임 내 관리)' })).toHaveCount(0); + await expect(page.getByText('설문 생성은 해당 게임의 설문 관리 화면에서 진행해 주세요.')).toHaveCount(0); +}); + +test('updates live game options from the authoritative database snapshot', async ({ page }, testInfo) => { + const fixture = await installFixture(page); + await page.goto('/gateway/admin/servers/hwe%3Adefault'); + + const settings = page.getByTestId('runtime-settings'); + await expect(settings).toBeVisible(); + await expect(page.getByTestId('runtime-turn-term')).toHaveValue('10'); + await expect(page.getByTestId('runtime-block-general-create')).toHaveValue('2'); + await expect(page.getByTestId('runtime-autorun-enabled')).not.toBeChecked(); + await expect(page.getByRole('button', { name: '설문 오픈 (게임 내 관리)' })).toHaveCount(0); + + await page.getByTestId('runtime-turn-term').selectOption('20'); + await page.getByTestId('runtime-block-general-create').selectOption('1'); + await page.getByTestId('runtime-autorun-enabled').check(); + await page.getByTestId('runtime-autorun-minutes').fill('720'); + for (const label of ['이동', '징병', '훈련', '전투']) { + await settings.getByLabel(label, { exact: true }).uncheck(); + } + await page.getByPlaceholder('사유 / 메모').fill('운영 중 설정 변경'); + + const submit = page.getByTestId('runtime-settings-submit'); + await submit.hover(); + const hoverBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor); + await submit.focus(); + await expect(submit).toBeFocused(); + const click = submit.click(); + await expect.poll(() => fixture.requestBodies.length).toBe(1); + await click; + + const requestJson = JSON.stringify(fixture.requestBodies[0]); + expect(requestJson).toContain('UPDATE_RUNTIME_SETTINGS'); + expect(requestJson).toContain('"turnTermMinutes":20'); + expect(requestJson).toContain('"blockGeneralCreate":1'); + expect(requestJson).toContain('"limitMinutes":720'); + expect(requestJson).toContain('"recruit_high"'); + expect(requestJson).toContain('"chief"'); + expect(hoverBackground).not.toBe('rgba(0, 0, 0, 0)'); + await expect(page.getByText('APPLIED · UPDATE_RUNTIME_SETTINGS')).toBeVisible(); + await expect(page.getByTestId('runtime-turn-term')).toHaveValue('20'); + await expect(page.getByTestId('runtime-block-general-create')).toHaveValue('1'); + await expect(page.getByTestId('runtime-autorun-enabled')).toBeChecked(); + + const geometry = await settings.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, width: rect.width, viewport: window.innerWidth }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(0); + expect(geometry.right).toBeLessThanOrEqual(geometry.viewport); + expect(geometry.width).toBeGreaterThan(250); + await page.screenshot({ path: testInfo.outputPath('runtime-settings-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + const mobileGeometry = await settings.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, viewport: window.innerWidth }; + }); + expect(mobileGeometry.left).toBeGreaterThanOrEqual(0); + expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewport); + await page.screenshot({ path: testInfo.outputPath('runtime-settings-mobile.png'), fullPage: true }); }); test('distinguishes a turn pause from an inaccessible stopped server in operator controls', async ({ page }) => { diff --git a/app/gateway-frontend/src/utils/resetDefaults.ts b/app/gateway-frontend/src/utils/resetDefaults.ts index 669a5fff..1d855431 100644 --- a/app/gateway-frontend/src/utils/resetDefaults.ts +++ b/app/gateway-frontend/src/utils/resetDefaults.ts @@ -1,4 +1,13 @@ -export const RESET_AUTORUN_OPTIONS = ['develop', 'warp', 'recruit', 'train', 'battle'] as const; +export const PROFILE_TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const; +export const RESET_AUTORUN_OPTIONS = [ + 'develop', + 'warp', + 'recruit', + 'recruit_high', + 'train', + 'battle', + 'chief', +] as const; export type ResetAutorunOption = (typeof RESET_AUTORUN_OPTIONS)[number]; @@ -48,7 +57,7 @@ export const normalizeProfileResetDefaults = (value: unknown): ProfileResetDefau const autorunLimit = rawAutorun?.limitMinutes; return { - turnTermMinutes: enumNumber(raw.turnTermMinutes, [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120], 60), + turnTermMinutes: enumNumber(raw.turnTermMinutes, PROFILE_TURN_TERM_MINUTES, 60), sync: typeof raw.sync === 'boolean' ? raw.sync : SYSTEM_PROFILE_RESET_DEFAULTS.sync, fiction: enumNumber(raw.fiction, [0, 1], SYSTEM_PROFILE_RESET_DEFAULTS.fiction), extend: typeof raw.extend === 'boolean' ? raw.extend : SYSTEM_PROFILE_RESET_DEFAULTS.extend, diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 4b0b1fec..f42dc142 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -12,6 +12,7 @@ import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import { useToast } from '../composables/useToast'; import { normalizeProfileResetDefaults, + PROFILE_TURN_TERM_MINUTES, type ProfileResetDefaults, type ResetAutorunOption, } from '../utils/resetDefaults'; @@ -201,6 +202,7 @@ type AdminProfile = { status: 'QUEUED' | 'RUNNING'; } | null; meta: Record; + runtimeSettings: ProfileRuntimeSettings | null; runtimeActions: Array<{ id: string; action: string; @@ -210,11 +212,22 @@ type AdminProfile = { handler: string | null; handledAt: string | null; createdAt: string; + payload?: Record; }>; }; +type ProfileRuntimeSettings = Pick; + type AdminAction = - 'RESUME' | 'PAUSE' | 'STOP' | 'ACCELERATE' | 'DELAY' | 'RESET_NOW' | 'RESET_SCHEDULED' | 'OPEN_SURVEY' | 'SHUTDOWN'; + | 'RESUME' + | 'PAUSE' + | 'STOP' + | 'ACCELERATE' + | 'DELAY' + | 'UPDATE_RUNTIME_SETTINGS' + | 'RESET_NOW' + | 'RESET_SCHEDULED' + | 'SHUTDOWN'; type AdminClient = { capabilities: { @@ -351,6 +364,7 @@ type AdminClient = { profileName: string; action: AdminAction; durationMinutes?: number; + runtimeSettings?: ProfileRuntimeSettings; scheduledAt?: string; reason?: string; }) => Promise<{ ok: boolean; action?: AdminProfile['runtimeActions'][number] }>; @@ -409,6 +423,11 @@ const profileActions = ref< durationMinutes: string; scheduledAt: string; reason: string; + turnTermMinutes: number; + blockGeneralCreate: 0 | 1 | 2; + autorunEnabled: boolean; + autorunUserMinutes: string; + autorunOptions: ResetAutorunOption[]; } > >({}); @@ -416,8 +435,10 @@ const resetAutorunLabels: Array<{ value: ResetAutorunOption; label: string }> = { value: 'develop', label: '내정' }, { value: 'warp', label: '이동' }, { value: 'recruit', label: '징병' }, + { value: 'recruit_high', label: '고급 징병' }, { value: 'train', label: '훈련' }, { value: 'battle', label: '전투' }, + { value: 'chief', label: '참모' }, ]; const profileActionStatus = ref>({}); const profileActionSubmitting = ref>({}); @@ -450,6 +471,16 @@ const validDuration = (profileName: string): boolean => { return Number.isInteger(value) && value >= 1 && value <= 1440; }; +const validRuntimeSettings = (profileName: string): boolean => { + const value = profileActions.value[profileName]; + if (!value || !PROFILE_TURN_TERM_MINUTES.some((minutes) => minutes === value.turnTermMinutes)) return false; + if (!value.autorunEnabled) return true; + const limitMinutes = Number(value.autorunUserMinutes); + return ( + Number.isInteger(limitMinutes) && limitMinutes >= 1 && limitMinutes <= 43200 && value.autorunOptions.length > 0 + ); +}; + const runtimeActionStatusClass = (status: AdminProfile['runtimeActions'][number]['status']): string => { if (status === 'APPLIED') return 'text-emerald-400'; if (status === 'FAILED') return 'text-red-400'; @@ -647,14 +678,31 @@ const ensureProfileBuffers = (profile: AdminProfile) => { }; } if (!profileActions.value[profile.profileName]) { + const settings = profile.runtimeSettings ?? normalizeProfileResetDefaults(profile.meta?.resetDefaults); profileActions.value[profile.profileName] = { durationMinutes: '', scheduledAt: '', reason: '', + turnTermMinutes: settings.turnTermMinutes, + blockGeneralCreate: settings.blockGeneralCreate, + autorunEnabled: settings.autorunUser !== null, + autorunUserMinutes: String(settings.autorunUser?.limitMinutes ?? 1440), + autorunOptions: settings.autorunUser?.options.slice() ?? resetAutorunLabels.map(({ value }) => value), }; } }; +const syncRuntimeSettingsBuffer = (profile: AdminProfile): void => { + const settings = profile.runtimeSettings; + const buffer = profileActions.value[profile.profileName]; + if (!settings || !buffer) return; + buffer.turnTermMinutes = settings.turnTermMinutes; + buffer.blockGeneralCreate = settings.blockGeneralCreate; + buffer.autorunEnabled = settings.autorunUser !== null; + buffer.autorunUserMinutes = String(settings.autorunUser?.limitMinutes ?? 1440); + buffer.autorunOptions = settings.autorunUser?.options.slice() ?? resetAutorunLabels.map(({ value }) => value); +}; + const toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value); const loadProfiles = async () => { @@ -688,6 +736,8 @@ const refreshRuntimeActionUntilTerminal = async (profileName: string, actionId: .find((profile) => profile.profileName === profileName) ?.runtimeActions.find((action) => action.id === actionId); if (current && isRuntimeActionTerminal(current.status)) { + const profile = profiles.value.find((item) => item.profileName === profileName); + if (profile && current.status === 'APPLIED') syncRuntimeSettingsBuffer(profile); profileActionStatus.value = { ...profileActionStatus.value, [profileName]: '', @@ -701,6 +751,13 @@ const refreshRuntimeActionUntilTerminal = async (profileName: string, actionId: ensureProfileBuffers(profile); }); profiles.value = result; + const refreshedAction = result + .find((profile) => profile.profileName === profileName) + ?.runtimeActions.find((action) => action.id === actionId); + if (refreshedAction?.status === 'APPLIED') { + const profile = result.find((item) => item.profileName === profileName); + if (profile) syncRuntimeSettingsBuffer(profile); + } } catch { // 일시적인 조회 실패는 다음 bounded poll에서 다시 확인합니다. } @@ -816,12 +873,37 @@ const requestProfileAction = async (profileName: string, action: AdminAction) => ? serverDateTimeInputToIso(actionState.scheduledAt) : undefined; const reason = actionState?.reason.trim() || undefined; + const runtimeSettings: ProfileRuntimeSettings | undefined = + action === 'UPDATE_RUNTIME_SETTINGS' && actionState + ? { + turnTermMinutes: actionState.turnTermMinutes, + blockGeneralCreate: actionState.blockGeneralCreate, + autorunUser: actionState.autorunEnabled + ? { + limitMinutes: Number(actionState.autorunUserMinutes), + options: actionState.autorunOptions, + } + : null, + } + : undefined; + if (action === 'UPDATE_RUNTIME_SETTINGS' && (!validRuntimeSettings(profileName) || !reason || reason.length < 3)) { + profileActionStatus.value = { + ...profileActionStatus.value, + [profileName]: + !reason || reason.length < 3 + ? '현재 기수 설정 변경 사유를 입력하세요.' + : '턴 간격과 유저 자동턴 값을 확인하세요.', + }; + profileActionSubmitting.value = { ...profileActionSubmitting.value, [profileName]: false }; + return; + } let runtimeActionId: string | undefined; try { const result = await adminClient.profiles.requestAction.mutate({ profileName, action, durationMinutes: durationValue, + runtimeSettings, scheduledAt, reason, }); @@ -830,7 +912,9 @@ const requestProfileAction = async (profileName: string, action: AdminAction) => [profileName]: result.action && (action === 'ACCELERATE' || action === 'DELAY') ? `접수됨 · ${result.action.status} · ${action} ${result.action.durationMinutes ?? ''}분` - : `요청 접수: ${action}`, + : result.action && action === 'UPDATE_RUNTIME_SETTINGS' + ? `접수됨 · ${result.action.status} · 현재 기수 설정 변경` + : `요청 접수: ${action}`, }; if (result.action) { runtimeActionId = result.action.id; @@ -2166,9 +2250,7 @@ onMounted(() => { data-testid="meta-reset-turn-term" > - - + + +