feat: 실행 중 게임 옵션 변경을 지원

Gateway 관리 화면에서 현재 기수의 장수 생성 제한, 유저 자동턴, 턴 간격을 내구성 action으로 변경한다.

턴 간격 변경은 논리 tick과 현재 게임 시각을 보존하며 DB와 Redis의 tick 기반 시각을 재투영하고 기존 로그 timestamp는 유지한다.
This commit is contained in:
2026-08-17 16:05:07 +00:00
parent 50e7d894e4
commit ca6823d409
26 changed files with 1680 additions and 115 deletions
+134 -1
View File
@@ -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<TurnDaemonCommand, { type: 'updateRuntimeSettings' }>
): Promise<TurnDaemonCommandResult> {
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: `<R>★</>턴시간이 <C>${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<TurnDaemonCommand, { type: 'troopJoin' }>
@@ -2505,6 +2636,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleSelectPoolReselect(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>),
shiftSchedule: (command) =>
handleShiftSchedule(ctx, command as Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>),
updateRuntimeSettings: (command) =>
handleUpdateRuntimeSettings(ctx, command as Extract<TurnDaemonCommand, { type: 'updateRuntimeSettings' }>),
};
return {