feat: add logical game clock
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
`
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user