feat: add logical game clock

This commit is contained in:
2026-08-04 02:51:27 +00:00
parent 87965a39d6
commit a26031dc3f
51 changed files with 1605 additions and 398 deletions
+2 -1
View File
@@ -192,7 +192,7 @@ export const createAuctionBidder = async (options: {
reason: '경매가 종료되었습니다.',
};
}
const now = new Date();
const now = world.getGameNow(new Date());
if (auction.closeAt.getTime() <= now.getTime()) {
return {
type: 'auctionBid',
@@ -379,6 +379,7 @@ export const createAuctionBidder = async (options: {
GamePrisma.sql`
UPDATE auction
SET close_at = ${nextCloseAt},
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
latest_event_id = ${eventId},
latest_event_at = ${eventAt},
updated_at = ${eventAt}
+7 -16
View File
@@ -1,12 +1,5 @@
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import {
ActionLogger,
ItemLoader,
LogFormat,
UserLogger,
isItemKey,
resolveUniqueConfig,
} from '@sammo-ts/logic';
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey, resolveUniqueConfig } from '@sammo-ts/logic';
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
import { asRecord, JosaUtil } from '@sammo-ts/common';
@@ -180,7 +173,7 @@ export const createAuctionFinalizer = async (options: {
);
const highestBid = bidRows[0] ?? null;
const now = new Date();
const now = world.getGameNow(new Date());
const logs: LogEntryDraft[] = [];
const globalLogger = new ActionLogger();
@@ -244,9 +237,7 @@ export const createAuctionFinalizer = async (options: {
const remainExtension = detail.remainCloseDateExtensionCnt ?? 0;
if (bidMeta.tryExtendCloseDate === true && remainExtension > 0) {
const turnMinutes = await resolveTurnMinutes(db);
const nextCloseAt = new Date(
auction.closeAt.getTime() + Math.max(5, turnMinutes) * 60_000
);
const nextCloseAt = new Date(auction.closeAt.getTime() + Math.max(5, turnMinutes) * 60_000);
const nextLatestBidCloseAt = new Date(
nextCloseAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) *
@@ -263,6 +254,7 @@ export const createAuctionFinalizer = async (options: {
SET status = 'OPEN',
detail = ${JSON.stringify(nextDetail)}::jsonb,
close_at = ${nextCloseAt},
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
updated_at = ${now}
WHERE id = ${auctionId}
`
@@ -419,6 +411,7 @@ export const createAuctionFinalizer = async (options: {
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
detail = ${JSON.stringify(nextDetail)}::jsonb,
close_at = ${nextCloseAt},
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
updated_at = ${now}
WHERE id = ${auctionId}
`
@@ -449,10 +442,7 @@ export const createAuctionFinalizer = async (options: {
);
const nextLatestBidCloseAt = new Date(
nextCloseAt.getTime() +
Math.max(
MIN_EXTENSION_MINUTES_PER_BID,
turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID
) *
Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) *
60_000
);
const nextDetail = {
@@ -467,6 +457,7 @@ export const createAuctionFinalizer = async (options: {
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
detail = ${JSON.stringify(nextDetail)}::jsonb,
close_at = ${nextCloseAt},
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
updated_at = ${now}
WHERE id = ${auctionId}
`
+6 -2
View File
@@ -94,7 +94,7 @@ const openResourceAuction = async (
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
}
const now = new Date();
const now = world.getGameNow(new Date());
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
const auction = await db.auction.create({
@@ -113,6 +113,8 @@ const openResourceAuction = async (
},
status: 'OPEN',
closeAt,
openTick: BigInt(world.dateToGameTick(now)),
closeTick: BigInt(world.dateToGameTick(closeAt)),
},
});
world.updateGeneral(general.id, {
@@ -218,7 +220,7 @@ const openUniqueAuction = async (
const state = world.getState();
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
const now = new Date();
const now = world.getGameNow(new Date());
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
const extensionLimitMinutes = Math.max(
@@ -250,6 +252,8 @@ const openUniqueAuction = async (
},
status: 'OPEN',
closeAt,
openTick: BigInt(world.dateToGameTick(now)),
closeTick: BigInt(world.dateToGameTick(closeAt)),
latestEventId: eventId,
latestEventAt: now,
bids: {
@@ -165,17 +165,33 @@ export class TurnDaemonLifecycle {
}
const nowMs = this.clock.nowMs();
const gameClock = await this.stateStore.loadGameClock?.(new Date(nowMs));
if (gameClock?.mode === 'manual') {
// Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that
// boundary instead of letting sub-minute general timestamps
// alter command/RNG order. After a restart, drain only turns
// strictly older than the persisted game time before moving on.
const gameNowMs = gameClock.now.getTime();
const hasOverdueGeneral = nextRunTime.getTime() < gameNowMs;
const targetTime = hasOverdueGeneral
? new Date(gameNowMs - 1)
: this.getNextTickTime(new Date(this.status.lastTurnTime!));
await this.runOnce({ reason: 'schedule', targetTime });
continue;
}
const gameNowMs = gameClock?.now.getTime() ?? nowMs;
const nextTurnMs = nextRunTime.getTime();
if (nowMs >= nextTurnMs) {
if (gameNowMs >= nextTurnMs) {
// Ref checkDelay() executes every turn due at the observed
// wall-clock time in one snapshot. Using only the oldest due
// timestamp lets generals created by that batch run before a
// monthly boundary, although Ref defers them to the next pass.
await this.runOnce({ reason: 'schedule', targetTime: new Date(nowMs) });
await this.runOnce({ reason: 'schedule', targetTime: new Date(gameNowMs) });
continue;
}
const command = await this.controlQueue.waitUntil(nextTurnMs);
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs));
if (command) {
await this.handleCommand(command);
}
@@ -354,6 +370,7 @@ export class TurnDaemonLifecycle {
try {
const runAndFlush = async (): Promise<TurnRunResult> => {
await this.stateStore.advanceGameClockTo?.(targetTime, new Date(startMs));
const nextResult = await this.processor.run(targetTime, budget, checkpoint);
fallbackError = 'Unknown turn flush error.';
this.status.state = 'flushing';
+3
View File
@@ -7,6 +7,7 @@ import type {
TurnRunResult,
} from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import type { GameClockMode } from '@sammo-ts/common';
export type {
RunReason,
@@ -51,6 +52,8 @@ export interface TurnStateStore {
saveLastTurnTime(turnTime: Date): Promise<void>;
loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
}
export interface TurnDaemonControlQueue {
+28 -1
View File
@@ -6,7 +6,7 @@ import {
type InputJsonValue,
type TurnEngineEventCreateManyInput,
} from '@sammo-ts/infra';
import { asNumber, asRecord } from '@sammo-ts/common';
import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common';
import {
buildScenarioBootstrap,
resolveScenarioGeneralDeathMonth,
@@ -66,6 +66,7 @@ export interface ScenarioSeedOptions {
resetTables?: boolean;
now?: Date;
tickSeconds?: number;
gameClockMode?: GameClockMode;
installOptions?: ScenarioInstallOptions;
includeNeutralNationInSeed?: boolean;
defaultGeneralGold?: number;
@@ -217,6 +218,15 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
const sync = install?.sync ?? false;
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
const gameClockMode = options.gameClockMode ?? 'realtime';
const initialClock = new GameClock({
baseTime: startState.startTime,
tick: 0,
mode: gameClockMode,
wallAnchor: now,
turnSeconds: tickSeconds,
});
const initialClockTick = initialClock.dateToTick(now);
const { seed, warnings } = buildScenarioBootstrap({
scenario: scenarioDefinition,
@@ -367,6 +377,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
currentYear: startState.currentYear,
currentMonth: startState.currentMonth,
tickSeconds,
clockBaseTime: initialClock.baseTime,
clockTick: BigInt(initialClockTick),
clockMode: gameClockMode,
clockWallAnchor: now,
lastTurnTick: BigInt(initialClockTick),
config: asJson({ ...scenarioConfig, ...worldConfig }),
meta: asJson(worldMeta),
},
@@ -523,6 +538,18 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
: 0) / 1_000
)
),
turnTick: BigInt(
initialClock.dateToTick(
new Date(
now.getTime() +
Math.floor(
(typeof general.meta.initialTurnOffsetMicros === 'number'
? general.meta.initialTurnOffsetMicros
: 0) / 1_000
)
)
)
),
age: resolveGeneralAge(startState.currentYear, general.birthYear),
// Legacy GeneralBuilder leaves startage at the schema default on install.
startAge: 20,
+8 -1
View File
@@ -1,5 +1,5 @@
import type { TurnSchedule } from '@sammo-ts/logic';
import { parseOptionalBoolean, parseOptionalNumber } from '@sammo-ts/common';
import { parseOptionalBoolean, parseOptionalNumber, type GameClockMode } from '@sammo-ts/common';
import type { TurnRunBudget } from '../lifecycle/types.js';
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
@@ -16,6 +16,7 @@ export interface TurnDaemonCliOptions {
budget?: Partial<TurnRunBudget>;
enableDatabaseFlush?: boolean;
adminActionIntervalMs?: number;
gameClockMode?: GameClockMode;
env?: NodeJS.ProcessEnv;
}
@@ -58,6 +59,11 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
const enableDatabaseFlush = options.enableDatabaseFlush ?? parseOptionalBoolean(env.TURN_FLUSH_DB) ?? true;
const pauseGateIntervalMs = parseOptionalNumber(env.TURN_PAUSE_GATE_MS);
const adminActionIntervalMs = options.adminActionIntervalMs ?? parseOptionalNumber(env.TURN_ADMIN_ACTION_MS);
const rawGameClockMode = options.gameClockMode ?? env.GAME_CLOCK_MODE;
if (rawGameClockMode && rawGameClockMode !== 'realtime' && rawGameClockMode !== 'manual') {
throw new Error(`GAME_CLOCK_MODE must be realtime or manual: ${rawGameClockMode}`);
}
const gameClockMode = rawGameClockMode as GameClockMode | undefined;
const runtime = await createTurnDaemonRuntime({
profile,
@@ -70,6 +76,7 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
enableDatabaseFlush,
pauseGateIntervalMs,
adminActionIntervalMs,
gameClockMode,
});
let closed = false;
+30 -2
View File
@@ -374,7 +374,10 @@ const buildGeneralUpdate = (
penalty: asJson(general.penalty ?? {}),
meta: buildPersistedGeneralMeta(general),
turnTime: general.turnTime,
turnTick: BigInt(general.turnTick ?? 0),
recentWarTime: general.recentWarTime ?? null,
recentWarTick:
general.recentWarTick === null || general.recentWarTick === undefined ? null : BigInt(general.recentWarTick),
});
const buildGeneralCreate = (
@@ -418,7 +421,10 @@ const buildGeneralCreate = (
penalty: asJson(general.penalty ?? {}),
meta: buildPersistedGeneralMeta(general),
turnTime: general.turnTime,
turnTick: BigInt(general.turnTick ?? 0),
recentWarTime: general.recentWarTime ?? null,
recentWarTick:
general.recentWarTick === null || general.recentWarTick === undefined ? null : BigInt(general.recentWarTick),
});
const buildCityUpdate = (
@@ -601,6 +607,11 @@ export const createDatabaseTurnHooks = async (
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
clockBaseTime: state.clockBaseTime ?? state.lastTurnTime,
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
meta: asJson(state.meta),
};
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
@@ -649,7 +660,8 @@ export const createDatabaseTurnHooks = async (
prisma,
lifecycleEvents,
meta,
asRecord(world.getScenarioConfig().const)
asRecord(world.getScenarioConfig().const),
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
);
if (inheritancePointAdjustments.length > 0) {
@@ -732,6 +744,8 @@ export const createDatabaseTurnHooks = async (
hostName: auction.hostName,
detail: asJson(auction.detail),
status: 'OPEN',
openTick: BigInt(state.clockTick ?? world.dateToGameTick(state.lastTurnTime)),
closeTick: BigInt(world.dateToGameTick(auction.closeAt)),
closeAt: auction.closeAt,
})),
});
@@ -959,15 +973,29 @@ export const createDatabaseTurnHooks = async (
await sendMessage(
{
insertMessage: async (draft: MessageRecordDraft) => {
const toTickOrNull = (date: Date): bigint | null => {
try {
return BigInt(world.dateToGameTick(date));
} catch {
// Legacy messages may use year 9999 as an
// effectively-unbounded expiry, beyond the
// safe JavaScript tick range.
return null;
}
};
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
INSERT INTO message (
mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message
)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${toTickOrNull(draft.time)},
${draft.validUntil},
${toTickOrNull(draft.validUntil)},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
@@ -157,7 +157,8 @@ const computeRate = (numerator: number, denominator: number): number => (denomin
const settleHall = async (
prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent,
worldMeta: Record<string, unknown>
worldMeta: Record<string, unknown>,
gameNow: Date
): Promise<void> => {
const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
if (isUnited !== 0) {
@@ -207,7 +208,7 @@ const settleHall = async (
bgColor: nation?.color ?? '#000000',
fgColor: resolveLegacyTextColor(nation?.color ?? '#000000'),
startTime: typeof worldMeta.starttime === 'string' ? worldMeta.starttime : null,
unitedTime: new Date().toISOString(),
unitedTime: gameNow.toISOString(),
ownerDisplayName:
typeof asRecord(event.before.meta).ownerDisplayName === 'string'
? asRecord(event.before.meta).ownerDisplayName
@@ -329,7 +330,8 @@ export const persistGeneralLifecycleEvents = async (
prisma: GamePrisma.TransactionClient,
events: GeneralLifecycleEvent[],
worldMeta: Record<string, unknown>,
configConst: Record<string, unknown>
configConst: Record<string, unknown>,
gameNow = new Date()
): Promise<void> => {
if (events.length === 0) {
return;
@@ -348,7 +350,7 @@ export const persistGeneralLifecycleEvents = async (
await settleInheritance(prisma, event, worldMeta, false, configConst);
}
if (event.outcome === 'retired') {
await settleHall(prisma, event, worldMeta);
await settleHall(prisma, event, worldMeta, gameNow);
await settleInheritance(prisma, event, worldMeta, true, configConst);
await prisma.rankData.updateMany({
where: { generalId: event.generalId },
@@ -28,4 +28,15 @@ export class InMemoryTurnStateStore implements TurnStateStore {
async saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void> {
this.world.setCheckpoint(checkpoint);
}
async loadGameClock(wallNow = new Date(Date.now())): Promise<{ mode: 'realtime' | 'manual'; now: Date }> {
return {
mode: this.world.getGameClockState().mode,
now: this.world.getGameNow(wallNow),
};
}
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
this.world.advanceGameClockTo(target, wallNow);
}
}
@@ -62,12 +62,12 @@ export class InMemoryTurnProcessor implements TurnProcessor {
// Ref processes `turntime < monthlyBoundary` before the monthly turn. A
// general exactly on the boundary therefore runs only after that month
// has advanced, on the daemon's following pass.
const useStrictGeneralCutoff =
firstTickTime.getTime() === targetTime.getTime() || targetTime.getTime() <= previousLastTurnTime.getTime();
const generalCutoff =
useStrictGeneralCutoff
? new Date(targetTime.getTime() - 1)
: targetTime;
// The monthly boundary itself stays strict (`turn_time < boundary`) like
// Ref. A manual clock may instead target an overdue general whose time
// is older than lastTurnTime; that exact general must be included or the
// daemon would repeatedly flush an empty run without advancing.
const useStrictGeneralCutoff = firstTickTime.getTime() === targetTime.getTime();
const generalCutoff = useStrictGeneralCutoff ? new Date(targetTime.getTime() - 1) : targetTime;
const dueGenerals = this.world.listDueGenerals(generalCutoff, checkpoint);
for (const general of dueGenerals) {
if (processedGenerals >= budget.maxGenerals || isBudgetExpired()) {
+206 -22
View File
@@ -9,6 +9,7 @@ import type {
UnitSetDefinition,
} from '@sammo-ts/logic';
import { getNextTurnAt } from '@sammo-ts/logic';
import { GameClock, type GameClockMode } from '@sammo-ts/common';
import type { TurnCheckpoint } from '../lifecycle/types.js';
import type {
@@ -105,6 +106,14 @@ export interface InMemoryTurnWorldOptions {
autoAdvanceDiplomacyMonth?: boolean;
}
export interface InMemoryGameClockState {
baseTime: Date;
tick: number;
mode: GameClockMode;
wallAnchor: Date;
lastTurnTick: number;
}
export interface TurnWorldChanges {
generals: TurnGeneral[];
cities: City[];
@@ -421,7 +430,36 @@ export class InMemoryTurnWorld {
private state: TurnWorldState;
constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) {
this.state = { ...state };
const baseTime = new Date((state.clockBaseTime ?? state.lastTurnTime).getTime());
const mode = state.clockMode ?? 'manual';
const wallAnchor = new Date((state.clockWallAnchor ?? state.lastTurnTime).getTime());
const bootstrapClock = new GameClock({
baseTime,
tick: state.clockTick ?? 0,
mode,
wallAnchor,
turnSeconds: state.tickSeconds,
});
const lastTurnTick = state.lastTurnTick ?? bootstrapClock.dateToTick(state.lastTurnTime);
const clockTick = state.clockTick ?? lastTurnTick;
const gameClock = new GameClock({
baseTime,
tick: clockTick,
mode,
wallAnchor,
turnSeconds: state.tickSeconds,
});
const lastTurnTime = gameClock.tickToDate(lastTurnTick);
this.state = {
...state,
clockBaseTime: baseTime,
clockTick,
clockMode: mode,
clockWallAnchor: wallAnchor,
lastTurnTick,
lastTurnTime,
meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() },
};
this.scenarioConfig = snapshot.scenarioConfig;
this.unitSet = snapshot.unitSet;
this.schedule = options.schedule;
@@ -435,7 +473,9 @@ export class InMemoryTurnWorld {
const worldKillturn = resolveWorldKillturn(this.state.meta);
for (const general of snapshot.generals) {
const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime);
const normalized = this.normalizeGeneralClock(
normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime)
);
const ensured = ensureGeneralKillturn(normalized, worldKillturn);
this.generals.set(general.id, ensured);
}
@@ -461,6 +501,67 @@ export class InMemoryTurnWorld {
this.ensureDiplomacyMatrix();
}
private getGameClock(): GameClock {
return new GameClock({
baseTime: this.state.clockBaseTime ?? this.state.lastTurnTime,
tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
turnSeconds: this.state.tickSeconds,
});
}
private normalizeGeneralClock(general: TurnGeneral): TurnGeneral {
const clock = this.getGameClock();
const turnTick = general.turnTick ?? clock.dateToTick(general.turnTime);
const recentWarTick =
general.recentWarTick !== undefined
? general.recentWarTick
: general.recentWarTime
? clock.dateToTick(general.recentWarTime)
: null;
return {
...general,
turnTick,
turnTime: clock.tickToDate(turnTick),
recentWarTick,
recentWarTime: recentWarTick === null ? null : clock.tickToDate(recentWarTick),
};
}
getGameClockState(): InMemoryGameClockState {
return {
baseTime: new Date((this.state.clockBaseTime ?? this.state.lastTurnTime).getTime()),
tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
lastTurnTick: this.state.lastTurnTick ?? 0,
};
}
getGameNow(wallNow: Date): Date {
return this.getGameClock().now(wallNow);
}
dateToGameTick(date: Date): number {
return this.getGameClock().dateToTick(date);
}
gameTickToDate(tick: number): Date {
return this.getGameClock().tickToDate(tick);
}
advanceGameClockTo(target: Date, wallNow: Date): void {
const clock = this.getGameClock();
const targetTick = clock.dateToTick(target);
const nextTick = Math.max(clock.tick, targetTick);
this.state = {
...this.state,
clockTick: nextTick,
clockWallAnchor: new Date(wallNow.getTime()),
};
}
captureState(): InMemoryTurnWorldStateSnapshot {
return structuredClone({
schedule: this.schedule,
@@ -573,17 +674,31 @@ export class InMemoryTurnWorld {
if (previousTickSeconds === nextTickSeconds) {
return;
}
const previousClock = this.getGameClock();
const anchorTick = this.state.clockTick ?? previousClock.tick;
const anchorDisplay = previousClock.tickToDate(anchorTick);
const nextBaseTime = GameClock.baseTimeForProjection(anchorDisplay, anchorTick, nextTickSeconds);
const ratio = nextTickSeconds / previousTickSeconds;
const baseTime = this.state.lastTurnTime.getTime();
for (const general of this.generals.values()) {
const nextTurnTime = new Date(baseTime + (general.turnTime.getTime() - baseTime) * ratio);
this.updateGeneral(general.id, { turnTime: nextTurnTime });
}
const nextGeneralTimes = new Map(
Array.from(this.generals.values(), (general) => [
general.id,
new Date(baseTime + (general.turnTime.getTime() - baseTime) * ratio),
])
);
this.schedule = { entries: [{ startMinute: 0, tickMinutes }] };
this.state = {
...this.state,
tickSeconds: nextTickSeconds,
clockBaseTime: nextBaseTime,
};
for (const general of this.generals.values()) {
const nextTurnTime = nextGeneralTimes.get(general.id);
if (!nextTurnTime) {
throw new Error(`Missing projected turn time for general ${general.id}.`);
}
this.updateGeneral(general.id, { turnTime: nextTurnTime });
}
}
pushLog(entry: LogEntryDraft): void {
@@ -739,7 +854,15 @@ export class InMemoryTurnWorld {
if (!target) {
return null;
}
const next = applyGeneralPatch(target, patch);
const next = this.normalizeGeneralClock(
applyGeneralPatch(target, {
...patch,
...(patch.turnTime && patch.turnTick === undefined ? { turnTick: undefined } : {}),
...(patch.recentWarTime !== undefined && patch.recentWarTick === undefined
? { recentWarTick: undefined }
: {}),
})
);
this.generals.set(id, next);
this.dirtyGeneralIds.add(id);
return next;
@@ -750,7 +873,9 @@ export class InMemoryTurnWorld {
return false;
}
const worldKillturn = resolveWorldKillturn(this.state.meta);
const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime);
const normalized = this.normalizeGeneralClock(
normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime)
);
const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn));
this.generals.set(general.id, ensured);
this.dirtyGeneralIds.add(general.id);
@@ -882,18 +1007,23 @@ export class InMemoryTurnWorld {
}
setLastTurnTime(turnTime: Date): void {
const clock = this.getGameClock();
const requestedTick = clock.dateToTick(turnTime);
const lastTurnTick = Math.max(this.state.lastTurnTick ?? requestedTick, requestedTick);
const projectedTime = clock.tickToDate(lastTurnTick);
const meta = {
...this.state.meta,
lastTurnTime: turnTime.toISOString(),
lastTurnTime: projectedTime.toISOString(),
};
this.state = {
...this.state,
lastTurnTime: new Date(turnTime.getTime()),
lastTurnTick,
lastTurnTime: projectedTime,
meta,
};
}
shiftSchedule(deltaMinutes: number): { shiftedGenerals: number; lastTurnTime: string } {
shiftSchedule(deltaMinutes: number, wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } {
if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) {
throw new Error('Schedule shift must be a non-zero integer number of minutes.');
}
@@ -931,7 +1061,27 @@ export class InMemoryTurnWorld {
);
};
const nextLastTurnTime = shiftDate(this.state.lastTurnTime);
const previousClock = this.getGameClock();
const generalTicks = new Map(
Array.from(this.generals.values(), (general) => [
general.id,
{
turnTick: general.turnTick ?? previousClock.dateToTick(general.turnTime),
recentWarTick:
general.recentWarTick ??
(general.recentWarTime ? previousClock.dateToTick(general.recentWarTime) : null),
},
])
);
const nextBaseTime = shiftDate(previousClock.baseTime);
const shiftedClock = new GameClock({
baseTime: nextBaseTime,
tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
turnSeconds: this.state.tickSeconds,
});
const nextLastTurnTime = shiftedClock.tickToDate(this.state.lastTurnTick ?? 0);
const nextMeta = {
...this.state.meta,
lastTurnTime: nextLastTurnTime.toISOString(),
@@ -941,12 +1091,27 @@ export class InMemoryTurnWorld {
};
this.state = {
...this.state,
clockBaseTime: nextBaseTime,
// Rebasing is also the explicit resume checkpoint. Realtime mode
// must not replay the operational downtime after an administrator
// deliberately delays or accelerates the game schedule.
clockWallAnchor: new Date(wallNow.getTime()),
lastTurnTime: nextLastTurnTime,
meta: nextMeta,
};
for (const general of this.generals.values()) {
this.updateGeneral(general.id, { turnTime: shiftDate(general.turnTime) });
const ticks = generalTicks.get(general.id);
if (!ticks) {
throw new Error(`Missing captured game ticks for general ${general.id}.`);
}
const { turnTick, recentWarTick } = ticks;
this.updateGeneral(general.id, {
turnTick,
turnTime: shiftedClock.tickToDate(turnTick),
recentWarTick,
recentWarTime: recentWarTick === null ? null : shiftedClock.tickToDate(recentWarTick),
});
}
for (const auction of this.pendingNeutralAuctions) {
auction.closeAt = shiftDate(auction.closeAt);
@@ -1055,10 +1220,13 @@ export class InMemoryTurnWorld {
const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
if (!result.deleted?.general) {
const nextGeneral = normalizeGeneralDatabaseIntegers({
...(result.general ?? currentGeneral),
turnTime: nextTurnAt,
});
const nextGeneral = this.normalizeGeneralClock(
normalizeGeneralDatabaseIntegers({
...(result.general ?? currentGeneral),
turnTime: nextTurnAt,
turnTick: undefined,
})
);
this.generals.set(nextGeneral.id, nextGeneral);
this.dirtyGeneralIds.add(nextGeneral.id);
}
@@ -1083,8 +1251,17 @@ export class InMemoryTurnWorld {
if (!target) {
continue;
}
const patched = applyGeneralPatch(target, patch.patch);
this.generals.set(patch.id, normalizeGeneralTurnTime(patched, this.state.lastTurnTime));
const patched = applyGeneralPatch(target, {
...patch.patch,
...(patch.patch.turnTime && patch.patch.turnTick === undefined ? { turnTick: undefined } : {}),
...(patch.patch.recentWarTime !== undefined && patch.patch.recentWarTick === undefined
? { recentWarTick: undefined }
: {}),
});
this.generals.set(
patch.id,
this.normalizeGeneralClock(normalizeGeneralTurnTime(patched, this.state.lastTurnTime))
);
this.dirtyGeneralIds.add(patch.id);
}
for (const patch of result.patches.cities) {
@@ -1127,7 +1304,9 @@ export class InMemoryTurnWorld {
continue;
}
const worldKillturn = resolveWorldKillturn(this.state.meta);
const normalized = normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime);
const normalized = this.normalizeGeneralClock(
normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime)
);
const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn));
this.generals.set(createdGeneral.id, ensured);
this.dirtyGeneralIds.add(createdGeneral.id);
@@ -1195,15 +1374,20 @@ export class InMemoryTurnWorld {
};
await this.calendarHandler?.beforeMonthChanged?.(context);
const clock = this.getGameClock();
const requestedTick = clock.dateToTick(turnTime);
const lastTurnTick = Math.max(this.state.lastTurnTick ?? requestedTick, requestedTick);
const lastTurnTime = clock.tickToDate(lastTurnTick);
const meta = {
...this.state.meta,
lastTurnTime: turnTime.toISOString(),
lastTurnTime: lastTurnTime.toISOString(),
};
this.state = {
...this.state,
currentYear: nextYear,
currentMonth: nextMonth,
lastTurnTime: new Date(turnTime.getTime()),
lastTurnTick,
lastTurnTime,
meta,
};
@@ -62,9 +62,7 @@ export const createOpenNationBettingHandler = (options: {
const currentLastId = world.getState().meta.lastBettingId;
const bettingId =
(typeof currentLastId === 'number' && Number.isFinite(currentLastId)
? Math.trunc(currentLastId)
: 0) + 1;
(typeof currentLastId === 'number' && Number.isFinite(currentLastId) ? Math.trunc(currentLastId) : 0) + 1;
world.updateWorldMeta({ lastBettingId: bettingId });
const shortName = nationCount === 1 ? '천통국' : `최후 ${nationCount}`;
@@ -88,10 +86,7 @@ export const createOpenNationBettingHandler = (options: {
targetCode: 'DESTROY_NATION',
priority: 1_000,
condition: ['RemainNation', '<=', nationCount],
action: [
['FinishNationBetting', bettingId],
['DeleteEvent'],
],
action: [['FinishNationBetting', bettingId], ['DeleteEvent']],
meta: {},
})
) {
@@ -109,7 +104,7 @@ export const createOpenNationBettingHandler = (options: {
});
const text = `새로운 ${shortName} 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.`;
const now = new Date();
const now = new Date(environment.turnTime.getTime());
for (const general of generals.filter((entry) => entry.npcState <= 1)) {
const nation = world.getNationById(general.nationId);
world.queueMessage({
+26 -34
View File
@@ -154,6 +154,25 @@ export class InMemoryReservedTurnStore {
} satisfies InMemoryReservedTurnStateSnapshot);
}
/**
* Hot-path transaction savepoint. Queue mutations replace complete turn
* arrays and journal sets instead of mutating captured entries in place,
* so retaining those immutable references is sufficient for rollback.
* Public inspection snapshots remain deep clones via captureState().
*/
captureTransactionState(): InMemoryReservedTurnStateSnapshot {
return {
generalTurns: Array.from(this.generalTurns.entries()),
nationTurns: Array.from(this.nationTurns.entries()),
dirtyGeneralIds: Array.from(this.dirtyGeneralIds),
dirtyNationKeys: Array.from(this.dirtyNationKeys),
pendingGeneralInitializationIds: Array.from(this.pendingGeneralInitializationIds),
pendingNationInitializationKeys: Array.from(this.pendingNationInitializationKeys),
leasedGeneralIds: Array.from(this.leasedGeneralIds),
leasedNationKeys: Array.from(this.leasedNationKeys),
};
}
restoreState(snapshot: InMemoryReservedTurnStateSnapshot): void {
const restored = structuredClone(snapshot);
this.replaceMap(this.generalTurns, restored.generalTurns);
@@ -232,11 +251,7 @@ export class InMemoryReservedTurnStore {
let claimed = await revisionStore.updateMany({
where: {
generalId,
OR: [
{ leaseOwner: this.leaseOwner },
{ leaseOwner: null },
{ leaseExpiresAt: { lte: now } },
],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
},
data: {
leaseOwner: this.leaseOwner,
@@ -252,11 +267,7 @@ export class InMemoryReservedTurnStore {
claimed = await revisionStore.updateMany({
where: {
generalId,
OR: [
{ leaseOwner: this.leaseOwner },
{ leaseOwner: null },
{ leaseExpiresAt: { lte: now } },
],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
},
data: {
leaseOwner: this.leaseOwner,
@@ -282,11 +293,7 @@ export class InMemoryReservedTurnStore {
where: {
nationId,
officerLevel,
OR: [
{ leaseOwner: this.leaseOwner },
{ leaseOwner: null },
{ leaseExpiresAt: { lte: now } },
],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
},
data: {
leaseOwner: this.leaseOwner,
@@ -303,11 +310,7 @@ export class InMemoryReservedTurnStore {
where: {
nationId,
officerLevel,
OR: [
{ leaseOwner: this.leaseOwner },
{ leaseOwner: null },
{ leaseExpiresAt: { lte: now } },
],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
},
data: {
leaseOwner: this.leaseOwner,
@@ -525,10 +528,7 @@ export class InMemoryReservedTurnStore {
}
}
private async claimGeneralFlushLease(
prisma: ReservedTurnDatabaseClient,
generalId: number
): Promise<boolean> {
private async claimGeneralFlushLease(prisma: ReservedTurnDatabaseClient, generalId: number): Promise<boolean> {
const revisionStore = prisma.generalTurnRevision;
if (!revisionStore) {
return false;
@@ -538,11 +538,7 @@ export class InMemoryReservedTurnStore {
? { generalId, leaseOwner: this.leaseOwner }
: {
generalId,
OR: [
{ leaseOwner: this.leaseOwner },
{ leaseOwner: null },
{ leaseExpiresAt: { lte: new Date() } },
],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
};
let claimed = await revisionStore.updateMany({
where,
@@ -613,11 +609,7 @@ export class InMemoryReservedTurnStore {
: {
nationId,
officerLevel,
OR: [
{ leaseOwner: this.leaseOwner },
{ leaseOwner: null },
{ leaseExpiresAt: { lte: new Date() } },
],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
};
let claimed = await revisionStore.updateMany({
where,
@@ -47,15 +47,18 @@ const syncAuctionTimers = async (
): Promise<number> => {
const auctions = await db.auction.findMany({
where: { status: 'OPEN' },
select: { id: true, closeAt: true },
select: { id: true, closeAt: true, closeTick: true },
});
if (auctions.length > 0) {
await redis.zAdd(
`sammo:${profileName}:auction:timer`,
auctions.map((auction) => ({
score: auction.closeAt.getTime(),
value: String(auction.id),
}))
auctions.map((auction) => {
const score = auction.closeTick == null ? auction.closeAt.getTime() : Number(auction.closeTick);
if (!Number.isSafeInteger(score)) {
throw new Error(`Auction ${auction.id} has an unsafe logical deadline: ${auction.closeTick}`);
}
return { score, value: String(auction.id) };
})
);
}
return auctions.length;
+35 -6
View File
@@ -1,5 +1,5 @@
import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
import { buildGameEventChannel, GameClock, type GameClockMode, type RealtimeEvent } from '@sammo-ts/common';
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
@@ -87,6 +87,7 @@ export interface TurnDaemonRuntimeOptions {
gatewayDatabaseUrl?: string;
defaultBudget?: TurnRunBudget;
clock?: Clock;
gameClockMode?: GameClockMode;
controlQueue?: TurnDaemonControlQueue;
schedule?: TurnSchedule;
tickMinutes?: number;
@@ -195,9 +196,31 @@ const createTurnDaemonRuntimeWithLease = async (
databaseUrl: options.databaseUrl,
mapOptions: options.mapOptions,
});
const clock = options.clock ?? new SystemClock();
const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes);
const resolvedState = options.tickMinutes ? { ...state, tickSeconds: tickMinutes * 60 } : state;
const nextTickSeconds = tickMinutes * 60;
const tickSecondsChanged = options.tickMinutes !== undefined && nextTickSeconds !== state.tickSeconds;
const clockBaseTime = tickSecondsChanged
? GameClock.baseTimeForProjection(
new GameClock({
baseTime: state.clockBaseTime ?? state.lastTurnTime,
tick: state.clockTick ?? state.lastTurnTick ?? 0,
mode: state.clockMode ?? 'manual',
wallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
turnSeconds: state.tickSeconds,
}).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0),
state.clockTick ?? state.lastTurnTick ?? 0,
nextTickSeconds
)
: state.clockBaseTime;
const modeChanged = options.gameClockMode !== undefined && options.gameClockMode !== state.clockMode;
const resolvedState = {
...state,
...(options.tickMinutes ? { tickSeconds: nextTickSeconds, clockBaseTime } : {}),
...(options.gameClockMode ? { clockMode: options.gameClockMode } : {}),
...(modeChanged ? { clockWallAnchor: new Date(clock.nowMs()) } : {}),
};
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
const hasEventAction = (name: string): boolean =>
snapshot.events.some(
@@ -219,6 +242,8 @@ const createTurnDaemonRuntimeWithLease = async (
? null
: await createReservedTurnStore({
databaseUrl: options.databaseUrl,
leaseOwner: options.leaseOwnerId,
leaseDurationMs: options.leaseDurationMs,
});
const commandProfile =
options.commandProfile ??
@@ -462,6 +487,7 @@ const createTurnDaemonRuntimeWithLease = async (
getWorldConfig: () => snapshot.worldConfig ?? null,
getNationPowerRollCount: () => monthlyNationPowerRollCount,
getTournamentRollConsumed: () => monthlyTournamentRollConsumed,
now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()),
});
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
profileName: options.profileName ?? options.profile,
@@ -475,7 +501,7 @@ const createTurnDaemonRuntimeWithLease = async (
// Deterministic/manual runtimes must schedule the tournament from the
// same clock that advances the game world. Production still falls
// back to the system clock.
now: () => new Date(options.clock?.nowMs() ?? Date.now()),
now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()),
});
const yearbookHandler = createYearbookHandler({
profileName: options.profileName ?? options.profile,
@@ -537,7 +563,7 @@ const createTurnDaemonRuntimeWithLease = async (
});
if (reservedTurnStoreHandle) {
stateManager.register('reservedTurns', {
capture: () => reservedTurnStoreHandle.store.captureState(),
capture: () => reservedTurnStoreHandle.store.captureTransactionState(),
restore: (captured) => reservedTurnStoreHandle.store.restoreState(captured),
inspect: () => reservedTurnStoreHandle.store.inspectState(),
});
@@ -604,7 +630,6 @@ const createTurnDaemonRuntimeWithLease = async (
: undefined,
});
const controlQueue = options.controlQueue ?? new InMemoryControlQueue();
const clock = options.clock ?? new SystemClock();
let hooks: TurnDaemonHooks | undefined;
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
@@ -785,7 +810,11 @@ const createTurnDaemonRuntimeWithLease = async (
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
commandHandler,
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
stateManager,
// The exclusive fixture runner aborts the entire in-memory runtime
// on failure and has no concurrent writer. Avoid cloning the whole
// accumulated world before every due tick in that isolated mode;
// production and gateway-managed runtimes keep rollback savepoints.
stateManager: options.exclusiveFastForward ? undefined : stateManager,
},
{ profile: options.profile, defaultBudget }
);
+8
View File
@@ -10,6 +10,7 @@ import type {
WorldSnapshot,
GeneralLastTurn,
} from '@sammo-ts/logic';
import type { GameClockMode } from '@sammo-ts/common';
export interface TurnWorldState {
id: number;
@@ -17,6 +18,11 @@ export interface TurnWorldState {
currentMonth: number;
tickSeconds: number;
lastTurnTime: Date;
clockBaseTime?: Date;
clockTick?: number;
clockMode?: GameClockMode;
clockWallAnchor?: Date;
lastTurnTick?: number;
meta: Record<string, unknown>;
}
@@ -29,7 +35,9 @@ export interface TurnGeneral extends General {
picture?: string | null;
imageServer?: number;
turnTime: Date;
turnTick?: number;
recentWarTime?: Date | null;
recentWarTick?: number | null;
lastTurn?: GeneralLastTurn;
penalty?: unknown;
inheritancePoints?: Record<string, number>;
@@ -173,6 +173,26 @@ const resolveCommandAcceptedAt = async (
return event.createdAt;
};
const resolveOperationalAcceptedAt = async (
db: DatabaseClient,
command: Pick<TurnDaemonCommand, 'type' | 'requestId'>
): Promise<Date> => {
if (!command.requestId) {
return new Date();
}
const event = await db.inputEvent.findUnique({
where: { requestId: command.requestId },
select: { createdAt: true, target: true, eventType: true },
});
if (!event) {
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
}
if (event.target !== 'ENGINE' || event.eventType !== command.type) {
throw new Error(`ENGINE input event type does not match ${command.type}.`);
}
return event.createdAt;
};
const assertImmediateGeneralActionActor = async (
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'buildNationCandidate' | 'instantRetreat' }>,
@@ -208,7 +228,8 @@ async function handleJoinCreateGeneral(
if (!worldState) {
throw new Error('Join world state is missing.');
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
try {
return {
type: 'joinCreateGeneral',
@@ -272,7 +293,8 @@ async function handleNpcPossessGeneral(
if (!worldState) {
throw new Error('NPC possession world state is missing.');
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
try {
return {
type: 'npcPossessGeneral',
@@ -313,7 +335,8 @@ async function handleSelectPoolCreate(
if (!worldState) {
throw new Error('Selection-pool world state is missing.');
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
try {
return {
type: 'selectPoolCreate',
@@ -356,7 +379,8 @@ async function handleSelectPoolReselect(
if (!worldState) {
throw new Error('Selection-pool world state is missing.');
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
try {
return {
type: 'selectPoolReselect',
@@ -832,7 +856,8 @@ async function handleShiftSchedule(
};
}
const shifted = ctx.world.shiftSchedule(command.deltaMinutes);
const operationalAcceptedAt = await resolveOperationalAcceptedAt(ctx.commandDb, command);
const shifted = ctx.world.shiftSchedule(command.deltaMinutes, operationalAcceptedAt);
const shiftedAuctions = await ctx.commandDb.$executeRaw(
GamePrisma.sql`
UPDATE auction
@@ -841,6 +866,46 @@ async function handleShiftSchedule(
WHERE status = 'OPEN'
`
);
await ctx.commandDb.$executeRaw(
GamePrisma.sql`
UPDATE select_pool
SET reserved_until = reserved_until + (${command.deltaMinutes} * INTERVAL '1 minute')
WHERE reserved_until IS NOT NULL
`
);
await ctx.commandDb.$executeRaw(
GamePrisma.sql`
UPDATE select_npc_token
SET valid_until = valid_until + (${command.deltaMinutes} * INTERVAL '1 minute'),
pick_more_from = pick_more_from + (${command.deltaMinutes} * INTERVAL '1 minute')
`
);
await ctx.commandDb.$executeRaw(
GamePrisma.sql`
UPDATE message
SET time = CASE WHEN time_tick IS NULL THEN time ELSE time + (${command.deltaMinutes} * INTERVAL '1 minute') END,
valid_until = CASE
WHEN valid_until_tick IS NULL THEN valid_until
ELSE valid_until + (${command.deltaMinutes} * INTERVAL '1 minute')
END
WHERE time_tick IS NOT NULL OR valid_until_tick IS NOT NULL
`
);
await ctx.commandDb.$executeRaw(
GamePrisma.sql`
UPDATE vote_poll
SET start_at = CASE WHEN start_tick IS NULL THEN start_at ELSE start_at + (${command.deltaMinutes} * INTERVAL '1 minute') END,
end_at = CASE
WHEN end_tick IS NULL THEN end_at
ELSE end_at + (${command.deltaMinutes} * INTERVAL '1 minute')
END,
closed_at = CASE
WHEN closed_at IS NULL THEN NULL
ELSE closed_at + (${command.deltaMinutes} * INTERVAL '1 minute')
END
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL OR closed_at IS NOT NULL
`
);
return {
type: 'shiftSchedule',
@@ -1809,7 +1874,7 @@ async function handleKick(
src: messageTarget,
dest: messageTarget,
text,
time: new Date(),
time: world.getGameNow(new Date()),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
option: {},
});
+91 -36
View File
@@ -24,9 +24,8 @@ import type {
import { normalizeScenarioEffect } from '@sammo-ts/logic';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
import { z } from 'zod';
import { asRecord, isRecord } from '@sammo-ts/common';
import { GameClock, asRecord, isRecord, type GameClockMode } from '@sammo-ts/common';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
@@ -78,6 +77,21 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string): number | nu
return null;
};
const toSafeTick = (value: bigint, field: string): number => {
const tick = Number(value);
if (!Number.isSafeInteger(tick)) {
throw new Error(`${field} is outside the JavaScript safe integer range: ${value}`);
}
return tick;
};
const parseClockMode = (value: string): GameClockMode => {
if (value === 'realtime' || value === 'manual') {
return value;
}
throw new Error(`world_state.clock_mode is invalid: ${value}`);
};
const zScenarioStatBlock = z.object({
total: z.number(),
min: z.number(),
@@ -130,38 +144,29 @@ const parseScenarioMeta = (meta: JsonRecord): ScenarioMeta | undefined => {
return parsed.success ? parsed.data : undefined;
};
const parseLastTurnTime = (meta: JsonRecord): Date | null => {
const parseLegacyLastTurnTime = (meta: JsonRecord): Date | null => {
const raw = meta.lastTurnTime;
if (typeof raw !== 'string') {
return null;
}
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed;
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const resolveFallbackTurnTimeBase = (generals: TurnGeneral[], updatedAt: Date | null): Date => {
let earliest: Date | null = null;
for (const general of generals) {
const turnTime = general.turnTime;
if (!earliest || turnTime.getTime() < earliest.getTime()) {
earliest = turnTime;
}
const resolveLegacyTurnTime = (
generalRows: readonly TurnEngineGeneralRow[],
meta: JsonRecord,
updatedAt: Date | null | undefined
): Date => {
const stored = parseLegacyLastTurnTime(meta);
if (stored) {
return stored;
}
if (earliest) {
return earliest;
}
if (updatedAt) {
return updatedAt;
}
return new Date();
};
const alignToPreviousTick = (base: Date, tickMinutes: number): Date => {
const nextTick = getNextTickTime(base, tickMinutes);
return new Date(nextTick.getTime() - tickMinutes * 60_000);
const earliest = generalRows.reduce<Date | null>(
(result, row) => (!result || row.turnTime.getTime() < result.getTime() ? row.turnTime : result),
null
);
return earliest ?? updatedAt ?? new Date();
};
const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
@@ -180,6 +185,7 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
const mapGeneralRow = (
row: TurnEngineGeneralRow,
gameClock: GameClock,
rankRows: readonly TurnEngineRankDataRow[],
inheritanceRows: readonly TurnEngineInheritancePointRow[],
accessRow?: TurnEngineGeneralAccessLogRow
@@ -248,8 +254,24 @@ const mapGeneralRow = (
lastTurn: normalizeGeneralLastTurn(row.lastTurn),
penalty: row.penalty,
// meta는 상단에서 보장 처리됨.
turnTime: row.turnTime,
recentWarTime: row.recentWarTime ?? null,
turnTick:
row.turnTick === null
? gameClock.dateToTick(row.turnTime)
: toSafeTick(row.turnTick, `general.turn_tick(${row.id})`),
turnTime:
row.turnTick === null
? row.turnTime
: gameClock.tickToDate(toSafeTick(row.turnTick, `general.turn_tick(${row.id})`)),
recentWarTick:
row.recentWarTick === null
? row.recentWarTime
? gameClock.dateToTick(row.recentWarTime)
: null
: toSafeTick(row.recentWarTick, `general.recent_war_tick(${row.id})`),
recentWarTime:
row.recentWarTick === null
? (row.recentWarTime ?? null)
: gameClock.tickToDate(toSafeTick(row.recentWarTick, `general.recent_war_tick(${row.id})`)),
inheritancePoints,
...(accessRow ? { refreshScoreTotal: accessRow.refreshScoreTotal } : {}),
};
@@ -376,6 +398,35 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
}),
]);
const meta = asRecord(worldState.meta);
const legacyLastTurnTime = resolveLegacyTurnTime(generalRows, meta, worldState.updatedAt);
const hasPersistedClock =
worldState.clockBaseTime !== null &&
worldState.clockTick !== null &&
worldState.clockWallAnchor !== null &&
worldState.lastTurnTick !== null;
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
const bootstrapClock = new GameClock({
baseTime: clockBaseTime,
tick: 0,
mode: clockMode,
wallAnchor: clockWallAnchor,
turnSeconds: worldState.tickSeconds,
});
const legacyLastTurnTick = bootstrapClock.dateToTick(legacyLastTurnTime);
const gameClock = new GameClock({
baseTime: clockBaseTime,
tick:
worldState.clockTick === null
? legacyLastTurnTick
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
mode: clockMode,
wallAnchor: clockWallAnchor,
turnSeconds: worldState.tickSeconds,
});
const ranksByGeneral = new Map<number, TurnEngineRankDataRow[]>();
for (const row of rankRows) {
const bucket = ranksByGeneral.get(row.generalId) ?? [];
@@ -396,6 +447,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
.map((row) =>
mapGeneralRow(
row,
gameClock,
ranksByGeneral.get(row.id) ?? [],
row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [],
accessByGeneral.get(row.id)
@@ -406,10 +458,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
const nations = nationRows.map(mapNationRow).sort((left, right) => left.id - right.id);
const diplomacy = diplomacyRows
.map(mapDiplomacyRow)
.sort(
(left, right) =>
left.fromNationId - right.fromNationId || left.toNationId - right.toNationId
);
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
const troops = troopRows.map(mapTroopRow).sort((left, right) => left.id - right.id);
const worldConfig = asRecord(worldState.config);
@@ -419,12 +468,13 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
const unitSetName = scenarioConfig.environment?.unitSet ?? 'che';
const unitSet = await loadUnitSetDefinitionByName(unitSetName, options.unitSetOptions);
const meta = asRecord(worldState.meta);
const scenarioMeta = parseScenarioMeta(meta);
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
const fallbackBase = resolveFallbackTurnTimeBase(generals, worldState.updatedAt ?? null);
const lastTurnTime = parseLastTurnTime(meta) ?? alignToPreviousTick(fallbackBase, tickMinutes);
const lastTurnTick =
worldState.lastTurnTick === null
? legacyLastTurnTick
: toSafeTick(worldState.lastTurnTick, 'world_state.last_turn_tick');
const lastTurnTime = gameClock.tickToDate(lastTurnTick);
const events = eventRows.filter((row) => row.targetCode !== 'initial').map(mapEventRow);
const initialEvents = eventRows.filter((row) => row.targetCode === 'initial').map(mapEventRow);
@@ -436,6 +486,11 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
currentMonth: worldState.currentMonth,
tickSeconds: worldState.tickSeconds,
lastTurnTime,
clockBaseTime: gameClock.baseTime,
clockTick: gameClock.tick,
clockMode,
clockWallAnchor: gameClock.wallAnchor,
lastTurnTick,
meta,
},
snapshot: {
@@ -257,7 +257,7 @@ describe('EngineStateManager', () => {
store.replaceGeneralTurns(1, { action: '훈련', args: { amount: 10 } });
const manager = new EngineStateManager();
manager.register('reservedTurns', {
capture: () => store.captureState(),
capture: () => store.captureTransactionState(),
restore: (snapshot) => store.restoreState(snapshot),
});
const before = store.captureState();
+28 -2
View File
@@ -36,7 +36,7 @@ const buildGeneral = (id: number, turnTime: string): TurnGeneral =>
npcState: 0,
}) as TurnGeneral;
const buildWorld = (): InMemoryTurnWorld => {
const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWorld => {
const state: TurnWorldState = {
id: 1,
currentYear: 190,
@@ -50,6 +50,7 @@ const buildWorld = (): InMemoryTurnWorld => {
tnmt_time: '2026-07-30 11:30:00',
untouched: 'keep',
},
...stateOverride,
};
const snapshot: TurnWorldSnapshot = {
generals: [buildGeneral(1, '2026-07-30T10:10:00.000Z'), buildGeneral(2, '2026-07-30T10:20:00.000Z')],
@@ -134,6 +135,29 @@ describe('runtime clock shift', () => {
tnmt_time: '2026-07-30 11:15:00',
});
});
it('rebases after two years of downtime without catching up missed turns', () => {
const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
const resumedAt = new Date('2028-07-29T10:00:00.000Z');
const deltaMinutes = 2 * 365 * 24 * 60;
const world = buildWorld({
clockBaseTime: wallAnchor,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: wallAnchor,
lastTurnTick: 0,
});
const beforeTurnTick = world.getGeneralById(1)?.turnTick;
world.shiftSchedule(deltaMinutes, resumedAt);
expect(world.getGameNow(resumedAt).toISOString()).toBe('2028-07-29T10:00:00.000Z');
expect(world.getGameNow(new Date(resumedAt.getTime() + 10 * 60_000)).toISOString()).toBe(
'2028-07-29T10:10:00.000Z'
);
expect(world.getGeneralById(1)?.turnTick).toBe(beforeTurnTick);
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
});
});
describe('runtime clock shift projection', () => {
@@ -186,7 +210,9 @@ describe('runtime clock shift projection', () => {
),
},
auction: {
findMany: vi.fn(async () => [{ id: 7, closeAt: new Date('2026-07-30T11:45:00.000Z') }]),
findMany: vi.fn(async () => [
{ id: 7, closeAt: new Date('2026-07-30T11:45:00.000Z'), closeTick: null },
]),
},
} as unknown as GamePrismaClient;
const values = new Map<string, string>([
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { SystemClock } from '../src/lifecycle/clock.js';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
import { getNextTickTime } from '../src/lifecycle/getNextTickTime.js';
@@ -73,12 +74,14 @@ integration('runtime clock shift persistence', () => {
await db.inputEvent.deleteMany({ where: { requestId } });
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } });
});
afterAll(async () => {
await db.inputEvent.deleteMany({ where: { requestId } });
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } });
await closeDb?.();
});
@@ -90,6 +93,11 @@ integration('runtime clock shift persistence', () => {
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
clockBaseTime: base,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: base,
lastTurnTick: 0,
config: {},
meta: {
lastTurnTime: base.toISOString(),
@@ -110,6 +118,7 @@ integration('runtime clock shift persistence', () => {
cityId: general.cityId,
troopId: general.troopId,
turnTime: general.turnTime,
turnTick: BigInt((general.id === generalIds[0] ? 1 : 2) * GAME_TICKS_PER_TURN),
})),
});
const auctionRows = await Promise.all(
@@ -132,6 +141,11 @@ integration('runtime clock shift persistence', () => {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: base,
clockBaseTime: base,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: base,
lastTurnTick: 0,
meta: row.meta as Record<string, unknown>,
};
const snapshot: TurnWorldSnapshot = {
@@ -229,13 +243,16 @@ integration('runtime clock shift persistence', () => {
generalId: 0,
});
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z');
expect((await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).meta).toMatchObject({
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
expect(storedWorld.meta).toMatchObject({
lastTurnTime: '2099-07-30T09:45:00.000Z',
starttime: '2099-06-30 23:45:00',
});
expect((await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } })).turnTime.toISOString()).toBe(
'2099-07-30T10:05:00.000Z'
);
expect(storedWorld.clockTick).toBe(0n);
expect(storedWorld.lastTurnTick).toBe(0n);
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } });
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z');
expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN));
const storedAuctions = await db.auction.findMany({
where: { id: { in: auctionRows.map((auction) => auction.id) } },
});
@@ -14,6 +14,173 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => {
it('runs manual game time to each monthly snapshot without waiting for wall time', async () => {
const wallNow = new Date('2026-01-01T00:00:00.000Z');
const operationalClock = new ManualClock(wallNow.getTime());
const queue = new InMemoryControlQueue();
let lastTurnTime = new Date('2042-01-01T00:00:00.000Z');
let gameNow = new Date(lastTurnTime);
const targets: string[] = [];
const lifecycle = new TurnDaemonLifecycle(
{
clock: operationalClock,
controlQueue: queue,
getNextTickTime: (value) => addMinutes(value, 60),
stateStore: {
loadLastTurnTime: async () => lastTurnTime,
loadNextGeneralTurnTime: async () => addMinutes(lastTurnTime, 30),
saveLastTurnTime: async (value) => {
lastTurnTime = value;
},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({ mode: 'manual', now: gameNow }),
advanceGameClockTo: async (target) => {
gameNow = target;
},
},
processor: {
run: async (target): Promise<TurnRunResult> => {
targets.push(target.toISOString());
if (targets.length === 3) {
queue.enqueue({ type: 'shutdown', reason: 'verified' });
}
return {
lastTurnTime: target.toISOString(),
processedGenerals: 0,
processedTurns: 1,
durationMs: 0,
partial: false,
};
},
},
},
{
profile: 'manual-clock',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
await lifecycle.start();
expect(targets).toEqual(['2042-01-01T01:00:00.000Z', '2042-01-01T02:00:00.000Z', '2042-01-01T03:00:00.000Z']);
expect(operationalClock.nowMs()).toBe(wallNow.getTime());
});
it('drains restart-overdue generals without advancing or catching up a month', async () => {
const gameNow = new Date('2042-01-01T03:00:00.000Z');
const queue = new InMemoryControlQueue();
const observedTargets: Date[] = [];
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
controlQueue: queue,
getNextTickTime: (value) => addMinutes(value, 60),
stateStore: {
loadLastTurnTime: async () => gameNow,
loadNextGeneralTurnTime: async () => addMinutes(gameNow, -30),
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({ mode: 'manual', now: gameNow }),
advanceGameClockTo: async () => {},
},
processor: {
run: async (target): Promise<TurnRunResult> => {
observedTargets.push(target);
queue.enqueue({ type: 'shutdown', reason: 'verified' });
return {
lastTurnTime: gameNow.toISOString(),
processedGenerals: 1,
processedTurns: 0,
durationMs: 0,
partial: false,
};
},
},
},
{
profile: 'manual-overdue',
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
}
);
await lifecycle.start();
expect(observedTargets[0]?.toISOString()).toBe('2042-01-01T02:59:59.999Z');
});
it('produces the same command, RNG, and resource state in realtime and manual modes', async () => {
const start = new Date('2042-01-01T00:00:00.000Z');
const runMode = async (mode: 'realtime' | 'manual') => {
const operationalClock = new ManualClock(
mode === 'realtime' ? start.getTime() + 3 * 60 * 60_000 : start.getTime()
);
const queue = new InMemoryControlQueue();
let lastTurnTime = new Date(start);
let gameNow = new Date(start);
let rng = 17;
let resource = 100;
const commands: string[] = [];
const lifecycle = new TurnDaemonLifecycle(
{
clock: operationalClock,
controlQueue: queue,
getNextTickTime: (value) => addMinutes(value, 60),
stateStore: {
loadLastTurnTime: async () => lastTurnTime,
loadNextGeneralTurnTime: async () => null,
saveLastTurnTime: async (value) => {
lastTurnTime = value;
},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async (wallNow) => ({
mode,
now:
mode === 'manual'
? gameNow
: new Date(start.getTime() + ((wallNow ?? start).getTime() - start.getTime())),
}),
advanceGameClockTo: async (target) => {
gameNow = target;
},
},
processor: {
run: async (target): Promise<TurnRunResult> => {
while (lastTurnTime.getTime() < target.getTime()) {
lastTurnTime = addMinutes(lastTurnTime, 60);
rng = (rng * 48_271) % 2_147_483_647;
const command = rng % 2 === 0 ? 'develop' : 'train';
commands.push(command);
resource += command === 'develop' ? 7 : -3;
}
if (commands.length >= 3) {
queue.enqueue({ type: 'shutdown', reason: `${mode} verified` });
}
return {
lastTurnTime: lastTurnTime.toISOString(),
processedGenerals: commands.length,
processedTurns: commands.length,
durationMs: 0,
partial: false,
};
},
},
},
{
profile: `${mode}-equivalence`,
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 10 },
}
);
await lifecycle.start();
return { commands, rng, resource, lastTurnTime: lastTurnTime.toISOString() };
};
expect(await runMode('manual')).toEqual(await runMode('realtime'));
});
it('restores engine state when a scheduled calculation throws', async () => {
const now = new Date('2026-01-01T00:10:00.000Z');
const queue = new InMemoryControlQueue();
+7
View File
@@ -170,6 +170,13 @@ describe('InMemoryTurnProcessor ordering', () => {
expect(world.getNextGeneralId()).toBe(4);
expect(world.getNextGeneralId()).toBe(5);
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });
const overdue = world.getGeneralById(1);
expect(overdue).toBeDefined();
overdue!.turnTime = addMinutes(baseTime, 5);
const overdueResult = await processor.run(addMinutes(baseTime, 5), budget);
expect(overdueResult.processedGenerals).toBe(1);
expect(executed.at(-1)).toBe(1);
});
it('stops catch-up immediately after a calendar handler finalizes unification', async () => {