feat: 게임 시계 reconciliation 권위 기반 추가

This commit is contained in:
2026-09-03 08:44:43 +00:00
parent b91dcbcaaa
commit ae7d55ef47
29 changed files with 1265 additions and 81 deletions
+9 -1
View File
@@ -65,7 +65,15 @@ export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick';
type WorldClockFields =
| 'clockBaseTime'
| 'clockTick'
| 'clockMode'
| 'clockWallAnchor'
| 'lastTurnTick'
| 'clockPhase'
| 'clockRevision'
| 'deadlineGeneration';
type GeneralClockFields = 'turnTick' | 'recentWarTick';
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma
+39 -3
View File
@@ -1,4 +1,10 @@
import { GameClock, type GameClockMode } from '@sammo-ts/common';
import {
GameClock,
inferClockPhase,
parseGameClockPhase,
type GameClockMode,
type GameClockPhase,
} from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
@@ -7,6 +13,9 @@ export interface CurrentGameTime {
wallNow: Date;
tick: number | null;
mode: GameClockMode | null;
phase?: GameClockPhase | null;
revision?: number | null;
deadlineGeneration?: number | null;
running: boolean;
startsAt: Date | null;
dateToTick(date: Date): number | null;
@@ -19,6 +28,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow,
tick: null,
mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true,
startsAt: null,
dateToTick: () => null,
@@ -32,6 +44,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockMode: true,
clockWallAnchor: true,
tickSeconds: true,
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
},
});
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
@@ -40,32 +55,53 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow,
tick: null,
mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true,
startsAt: null,
dateToTick: () => null,
};
}
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
// During the dual-read migration an older profile (or a rolling-deploy
// fixture) can lack clock_phase. Preserve the existing future-anchor
// PREOPEN contract until every profile has the authoritative column.
const phase = state.clockPhase
? parseGameClockPhase(state.clockPhase)
: mode === 'realtime' && wallNow.getTime() < state.clockWallAnchor.getTime()
? 'PREOPEN'
: inferClockPhase(mode);
const storedTick = Number(state.clockTick);
if (!Number.isSafeInteger(storedTick)) {
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`);
}
const revision = Number(state.clockRevision ?? 1n);
const deadlineGeneration = Number(state.deadlineGeneration ?? 1n);
if (!Number.isSafeInteger(revision) || !Number.isSafeInteger(deadlineGeneration)) {
throw new Error('world_state clock revision or deadline generation is outside the safe integer range.');
}
const clock = new GameClock({
baseTime: state.clockBaseTime,
tick: storedTick,
mode,
wallAnchor: state.clockWallAnchor,
turnSeconds: state.tickSeconds,
phase,
revision,
});
const tick = clock.nowTick(wallNow);
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime();
const running = phase === 'RUNNING' && mode === 'realtime';
return {
now: clock.tickToDate(tick),
wallNow,
tick,
mode,
phase,
revision,
deadlineGeneration,
running,
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null,
startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null,
dateToTick: (date) => clock.dateToTick(date),
};
};
+12 -2
View File
@@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js';
import { loadCurrentGameTime } from '../src/services/gameClock.js';
const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient =>
const buildDatabase = (
mode: 'realtime' | 'manual' = 'realtime',
phase: 'PREOPEN' | 'RUNNING' | 'MANUAL' = mode === 'manual' ? 'MANUAL' : 'PREOPEN'
): DatabaseClient =>
({
worldState: {
findFirst: vi.fn(async () => ({
@@ -12,6 +15,9 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
clockMode: mode,
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
tickSeconds: 600,
clockPhase: phase,
clockRevision: 1n,
deadlineGeneration: 1n,
})),
},
}) as unknown as DatabaseClient;
@@ -26,11 +32,15 @@ describe('current game time projection', () => {
wallNow: new Date('2026-08-21T10:30:00.000Z'),
tick: -108_000_000,
mode: 'realtime',
phase: 'PREOPEN',
running: false,
startsAt: new Date('2026-08-21T11:00:00.000Z'),
});
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
const opened = await loadCurrentGameTime(
buildDatabase('realtime', 'RUNNING'),
new Date('2026-08-21T11:00:05.000Z')
);
expect(opened).toMatchObject({
now: new Date('2026-08-21T11:00:05.000Z'),
tick: 300_000,
@@ -172,7 +172,20 @@ export class TurnDaemonLifecycle {
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
const gameClock = await this.stateStore.loadGameClock?.(wallNow);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (
gameClock?.phase &&
gameClock.phase !== 'RUNNING' &&
gameClock.phase !== 'MANUAL'
) {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
if (gameClock?.mode === 'manual') {
// Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that
+9 -2
View File
@@ -7,7 +7,7 @@ import type {
TurnRunResult,
} from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import type { GameClockMode } from '@sammo-ts/common';
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
export type {
RunReason,
@@ -60,7 +60,14 @@ export interface TurnStateStore {
loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
shouldHaltScheduledRuns?(): Promise<boolean>;
loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>;
loadGameClock?(wallNow?: Date): Promise<{
mode: GameClockMode;
now: Date;
phase?: GameClockPhase;
revision?: number;
deadlineGeneration?: number;
}>;
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
+34 -9
View File
@@ -6,7 +6,7 @@ import {
type InputJsonValue,
type TurnEngineEventCreateManyInput,
} from '@sammo-ts/infra';
import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common';
import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common';
import {
buildScenarioBootstrap,
resolveScenarioGeneralDeathMonth,
@@ -94,6 +94,17 @@ export const calculateInitialTurnTick = (
return clock.addTicks(baseTick, offsetTicks);
};
export const resolveInitialClockPhase = (
mode: GameClockMode,
seededAtWall: Date,
scheduledOpenAtWall: Date
): GameClockPhase => {
if (mode === 'manual') {
return 'MANUAL';
}
return scheduledOpenAtWall.getTime() > seededAtWall.getTime() ? 'PREOPEN' : 'RUNNING';
};
const formatDateTime = (date: Date): string => {
const pad = (value: number): string => String(value).padStart(2, '0');
return [
@@ -234,14 +245,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands.
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
const initialClock = new GameClock({
baseTime: startState.startTime,
tick: 0,
mode: gameClockMode,
wallAnchor: initialClockWallAnchor,
turnSeconds: tickSeconds,
phase: initialClockPhase,
revision: 1,
});
const initialClockTick = initialClock.dateToTick(now);
// The formal opening wall instant is always logical tick zero. PREOPEN may
// project signed negative observed ticks, but executable seed schedules are
// derived from this zero coordinate rather than from the seed wall time.
const initialClockTick = 0;
const { seed, warnings } = buildScenarioBootstrap({
scenario: scenarioDefinition,
@@ -327,6 +344,10 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
}
worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = now.toISOString();
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
worldMeta.calendarStart = startState.startTime.toISOString();
if (install?.preopenAt) {
worldMeta.preopenAt = formatDateTime(install.preopenAt);
@@ -420,6 +441,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
clockMode: gameClockMode,
clockWallAnchor: initialClock.wallAnchor,
lastTurnTick: BigInt(initialClockTick),
clockPhase: initialClockPhase,
clockRevision: 1n,
deadlineGeneration: 1n,
config: asJson({ ...scenarioConfig, ...worldConfig }),
meta: asJson(worldMeta),
},
@@ -590,13 +614,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
weaponCode: general.weapon ?? 'None',
bookCode: general.book ?? 'None',
itemCode: general.item ?? 'None',
turnTime: new Date(
now.getTime() +
Math.floor(
(typeof general.meta.initialTurnOffsetMicros === 'number'
? general.meta.initialTurnOffsetMicros
: 0) / 1_000
)
turnTime: initialClock.tickToDate(
calculateInitialTurnTick(
initialClock,
initialClockTick,
typeof general.meta.initialTurnOffsetMicros === 'number'
? general.meta.initialTurnOffsetMicros
: 0
)
),
turnTick: BigInt(
calculateInitialTurnTick(
+61 -14
View File
@@ -1,5 +1,6 @@
import {
acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
@@ -122,6 +123,10 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'clock_base_time',
'clockMode',
'clock_mode',
'clockPhase',
'clock_phase',
'clockRevision',
'clock_revision',
'clockTick',
'clock_tick',
'clockWallAnchor',
@@ -135,6 +140,8 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'last_turn_tick',
'lastTurnTime',
'last_turn_time',
'deadlineGeneration',
'deadline_generation',
'lease',
'leaseOwner',
'lease_owner',
@@ -1122,9 +1129,20 @@ export const createDatabaseTurnHooks = async (
clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: asJson(world.getWorldConfig()),
meta: asJson(state.meta),
};
const writesGeneralAccess =
accessScoreResetGeneralIds.length > 0 ||
lifecycleEvents.length > 0 ||
deletedGenerals.length > 0 ||
generals.some(
(general) =>
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
);
const persist = async (
prisma: GamePrisma.TransactionClient
): Promise<{
@@ -1157,6 +1175,49 @@ export const createDatabaseTurnHooks = async (
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
await options?.turnDaemonLease?.assertActive(prisma);
await acquireGameSchemaAdvisoryXactLock(prisma, CLOCK_OPERATION_PERSISTENCE_LOCK);
if (writesGeneralAccess) {
// General-access API writers take this lock before touching
// world rows. Clock operations use the same global order.
await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK);
}
const persistedClock = await prisma.$queryRaw<
Array<{
clock_phase: string;
clock_revision: bigint;
deadline_generation: bigint;
opening_reached: boolean;
}>
>(GamePrisma.sql`
SELECT clock_phase,
clock_revision,
deadline_generation,
clock_wall_anchor <= CURRENT_TIMESTAMP AS opening_reached
FROM world_state
WHERE id = ${state.id}
FOR UPDATE
`);
const durableClock = persistedClock[0];
if (!durableClock) {
throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`);
}
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
const expectedRevision = BigInt(state.clockRevision ?? 1);
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
const openingPhaseTransition =
durableClock.clock_phase === 'PREOPEN' &&
expectedPhase === 'RUNNING' &&
durableClock.opening_reached;
if (
(!openingPhaseTransition && durableClock.clock_phase !== expectedPhase) ||
durableClock.clock_revision !== expectedRevision ||
durableClock.deadline_generation !== expectedGeneration
) {
throw new Error(
`Game clock fence changed before flush: expected ${expectedPhase}@${expectedRevision}/${expectedGeneration}, ` +
`found ${durableClock.clock_phase}@${durableClock.clock_revision}/${durableClock.deadline_generation}.`
);
}
let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey =
@@ -1323,20 +1384,6 @@ export const createDatabaseTurnHooks = async (
const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle');
const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle');
const writesGeneralAccess =
accessScoreResetGeneralIds.length > 0 ||
lifecycleEvents.length > 0 ||
deletedGenerals.length > 0 ||
generals.some(
(general) =>
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
);
if (writesGeneralAccess) {
// API access writers acquire this before traffic/access rows.
// Match that order before lifecycle and monthly score writes.
await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK);
}
await persistInheritancePointAdjustments(beforeLifecycleAdjustments);
await persistInheritanceLogs(beforeLifecycleLogs);
await persistGeneralLifecycleEvents(
+16 -2
View File
@@ -35,13 +35,27 @@ export class InMemoryTurnStateStore implements TurnStateStore {
return asNumber(meta.isunited ?? meta.isUnited, 0) >= 2;
}
async loadGameClock(wallNow = new Date(Date.now())): Promise<{ mode: 'realtime' | 'manual'; now: Date }> {
async loadGameClock(wallNow = new Date(Date.now())): Promise<{
mode: 'realtime' | 'manual';
now: Date;
phase: ReturnType<InMemoryTurnWorld['getGameClockState']>['phase'];
revision: number;
deadlineGeneration: number;
}> {
const state = this.world.getGameClockState();
return {
mode: this.world.getGameClockState().mode,
mode: state.mode,
now: this.world.getGameNow(wallNow),
phase: state.phase,
revision: state.revision,
deadlineGeneration: state.deadlineGeneration,
};
}
async promotePreopenAtOpening(wallNow: Date): Promise<boolean> {
return this.world.promotePreopenAtOpening(wallNow);
}
async rebaseRealtimeBacklog(wallNow: Date) {
return this.world.rebaseRealtimeBacklog(wallNow);
}
+50 -3
View File
@@ -10,7 +10,14 @@ import type {
UnitSetDefinition,
} from '@sammo-ts/logic';
import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic';
import { GAME_TICKS_PER_TURN, GameClock, type GameClockMode } from '@sammo-ts/common';
import {
GAME_TICKS_PER_TURN,
GameClock,
assertGameplayCommitAllowed,
inferClockPhase,
type GameClockMode,
type GameClockPhase,
} from '@sammo-ts/common';
import type { TurnCheckpoint } from '../lifecycle/types.js';
import type {
@@ -123,6 +130,9 @@ export interface InMemoryGameClockState {
mode: GameClockMode;
wallAnchor: Date;
lastTurnTick: number;
phase: GameClockPhase;
revision: number;
deadlineGeneration: number;
}
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
@@ -525,6 +535,9 @@ export class InMemoryTurnWorld {
constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) {
const baseTime = new Date((state.clockBaseTime ?? state.lastTurnTime).getTime());
const mode = state.clockMode ?? 'manual';
const phase = state.clockPhase ?? inferClockPhase(mode);
const revision = state.clockRevision ?? 1;
const deadlineGeneration = state.deadlineGeneration ?? 1;
const wallAnchor = new Date((state.clockWallAnchor ?? state.lastTurnTime).getTime());
const bootstrapClock = new GameClock({
baseTime,
@@ -532,6 +545,8 @@ export class InMemoryTurnWorld {
mode,
wallAnchor,
turnSeconds: state.tickSeconds,
phase,
revision,
});
const lastTurnTick = state.lastTurnTick ?? bootstrapClock.dateToTick(state.lastTurnTime);
const clockTick = state.clockTick ?? lastTurnTick;
@@ -541,6 +556,8 @@ export class InMemoryTurnWorld {
mode,
wallAnchor,
turnSeconds: state.tickSeconds,
phase,
revision,
});
const lastTurnTime = gameClock.tickToDate(lastTurnTick);
this.state = {
@@ -550,6 +567,9 @@ export class InMemoryTurnWorld {
clockMode: mode,
clockWallAnchor: wallAnchor,
lastTurnTick,
clockPhase: phase,
clockRevision: revision,
deadlineGeneration,
lastTurnTime,
meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() },
};
@@ -621,6 +641,8 @@ export class InMemoryTurnWorld {
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
});
}
@@ -649,6 +671,9 @@ export class InMemoryTurnWorld {
mode: this.state.clockMode ?? 'manual',
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
lastTurnTick: this.state.lastTurnTick ?? 0,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
deadlineGeneration: this.state.deadlineGeneration ?? 1,
};
}
@@ -656,11 +681,26 @@ export class InMemoryTurnWorld {
return this.getGameClock().now(wallNow);
}
promotePreopenAtOpening(wallNow: Date): boolean {
const clock = this.getGameClock();
if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) {
return false;
}
if (clock.tick !== 0) {
throw new Error(`PREOPEN opening invariant requires clock tick zero, found ${clock.tick}.`);
}
this.state = {
...this.state,
clockPhase: 'RUNNING',
};
return true;
}
getRunnableGameNow(wallNow: Date): Date {
const clock = this.getGameClock();
// PREOPEN still needs negative game ticks for cooldowns, but executable
// turn schedules must not precede the wall-clock opening boundary.
if (clock.mode === 'realtime' && wallNow.getTime() < clock.wallAnchor.getTime()) {
if (clock.phase === 'PREOPEN') {
return clock.now(clock.wallAnchor);
}
return clock.now(wallNow);
@@ -676,6 +716,7 @@ export class InMemoryTurnWorld {
advanceGameClockTo(target: Date, wallNow: Date): void {
const clock = this.getGameClock();
assertGameplayCommitAllowed(clock.phase);
const targetTick = clock.dateToTick(target);
// Realtime의 권위 시각은 wall anchor 이후 경과입니다. 밀린 턴을 과거
// target으로 처리한 완료 시각에 anchor를 다시 고정하면, 처리에 걸린
@@ -696,7 +737,7 @@ export class InMemoryTurnWorld {
skippedTurns: number;
} | null {
const clock = this.getGameClock();
if (clock.mode !== 'realtime') {
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') {
return null;
}
const turnMinutes = Math.max(1, Math.round(this.state.tickSeconds / 60));
@@ -969,6 +1010,8 @@ export class InMemoryTurnWorld {
mode: this.state.clockMode ?? 'manual',
wallAnchor: anchorWall,
turnSeconds: nextTickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
});
const lastTurnTick = this.state.lastTurnTick ?? previousClock.dateToTick(this.state.lastTurnTime);
const nextLastTurnTime = nextClock.tickToDate(lastTurnTick);
@@ -1470,6 +1513,8 @@ export class InMemoryTurnWorld {
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
});
const nextLastTurnTime = shiftedClock.tickToDate(this.state.lastTurnTick ?? 0);
const nextMeta = {
@@ -1605,6 +1650,7 @@ export class InMemoryTurnWorld {
}
executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution {
assertGameplayCommitAllowed(this.getGameClock().phase);
const currentGeneral = this.generals.get(general.id) ?? general;
const executionYear = this.state.currentYear;
const executionMonth = this.state.currentMonth;
@@ -1787,6 +1833,7 @@ export class InMemoryTurnWorld {
}
async advanceMonth(turnTime: Date): Promise<void> {
assertGameplayCommitAllowed(this.getGameClock().phase);
const previousYear = this.state.currentYear;
const previousMonth = this.state.currentMonth;
let nextYear = previousYear;
+2
View File
@@ -219,6 +219,8 @@ const resolveRuntimeState = (
mode: state.clockMode ?? 'manual',
wallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
turnSeconds: state.tickSeconds,
phase: state.clockPhase,
revision: state.clockRevision,
}).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0),
state.clockTick ?? state.lastTurnTick ?? 0,
nextTickSeconds
+4 -1
View File
@@ -11,7 +11,7 @@ import type {
WorldSnapshot,
GeneralLastTurn,
} from '@sammo-ts/logic';
import type { GameClockMode } from '@sammo-ts/common';
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
export interface TurnWorldState {
id: number;
@@ -24,6 +24,9 @@ export interface TurnWorldState {
clockMode?: GameClockMode;
clockWallAnchor?: Date;
lastTurnTick?: number;
clockPhase?: GameClockPhase;
clockRevision?: number;
deadlineGeneration?: number;
meta: Record<string, unknown>;
}
+23 -1
View File
@@ -26,7 +26,14 @@ import { normalizeScenarioEffect } from '@sammo-ts/logic';
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
import { z } from 'zod';
import { GameClock, asRecord, isRecord, type GameClockMode } from '@sammo-ts/common';
import {
GameClock,
asRecord,
inferClockPhase,
isRecord,
parseGameClockPhase,
type GameClockMode,
} from '@sammo-ts/common';
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
@@ -433,6 +440,14 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
worldState.clockWallAnchor !== null &&
worldState.lastTurnTick !== null;
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
const clockPhase = hasPersistedClock
? parseGameClockPhase(worldState.clockPhase)
: inferClockPhase(clockMode);
const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision');
const deadlineGeneration = toSafeTick(
worldState.deadlineGeneration,
'world_state.deadline_generation'
);
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
const bootstrapClock = new GameClock({
@@ -441,6 +456,8 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
mode: clockMode,
wallAnchor: clockWallAnchor,
turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
});
const legacyLastTurnTick = bootstrapClock.dateToTick(legacyLastTurnTime);
const gameClock = new GameClock({
@@ -452,6 +469,8 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
mode: clockMode,
wallAnchor: clockWallAnchor,
turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
});
const ranksByGeneral = new Map<number, TurnEngineRankDataRow[]>();
@@ -519,6 +538,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
clockMode,
clockWallAnchor: gameClock.wallAnchor,
lastTurnTick,
clockPhase,
clockRevision,
deadlineGeneration,
meta,
},
snapshot: {
@@ -183,17 +183,43 @@ describe('runtime clock shift', () => {
clockMode: 'realtime',
clockWallAnchor: openAt,
lastTurnTick: 0,
clockPhase: 'PREOPEN',
});
const preopenAt = new Date('2026-09-02T23:03:00.000Z');
expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime());
expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase);
expect(world.getRunnableGameNow(openAt)).toEqual(gameBase);
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual(
new Date(gameBase.getTime() + 60_000)
);
});
it('promotes PREOPEN only at an opening tick-zero anchor', () => {
const openAt = new Date('2026-09-02T23:30:00.000Z');
const world = buildWorld({
clockBaseTime: new Date('2026-07-30T10:00:00.000Z'),
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: openAt,
lastTurnTick: 0,
clockPhase: 'PREOPEN',
});
expect(world.promotePreopenAtOpening(new Date(openAt.getTime() - 1))).toBe(false);
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
});
it('rejects gameplay commits while the durable clock is suspended', async () => {
const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' });
expect(() => world.advanceGameClockTo(new Date(), new Date())).toThrow(/SUSPENDED/);
expect(() => world.executeGeneralTurn(world.listGenerals()[0]!)).toThrow(/SUSPENDED/);
await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/);
});
it.each([
[5, 6],
[10, 3],
@@ -1,6 +1,6 @@
import { GameClock } from '@sammo-ts/common';
import { describe, expect, test } from 'vitest';
import { calculateInitialTurnTick } from '../src/scenario/scenarioSeeder.js';
import { calculateInitialTurnTick, resolveInitialClockPhase } from '../src/scenario/scenarioSeeder.js';
describe('scenario seeder general turn tick', () => {
test('preserves Ref-compatible sub-millisecond RNG precision', () => {
@@ -17,4 +17,31 @@ describe('scenario seeder general turn tick', () => {
expect(calculateInitialTurnTick(clock, baseTick, 235_265_319)).toBe(baseTick + 14_115_919);
expect(clock.dateToTick(new Date(now.getTime() + 235_265))).toBe(baseTick + 14_115_900);
});
test('keeps formal opening at tick zero while PREOPEN projects signed ticks', () => {
const seededAt = new Date('2030-01-01T01:00:00.000Z');
const openAt = new Date('2030-01-01T02:00:00.000Z');
const phase = resolveInitialClockPhase('realtime', seededAt, openAt);
const clock = new GameClock({
baseTime: new Date('0190-01-01T00:00:00.000Z'),
tick: 0,
mode: 'realtime',
wallAnchor: openAt,
turnSeconds: 600,
phase,
});
expect(phase).toBe('PREOPEN');
expect(clock.nowTick(seededAt)).toBe(-6 * 36_000_000);
expect(clock.nowTick(openAt)).toBe(0);
expect(calculateInitialTurnTick(clock, 0, 0)).toBe(0);
expect(calculateInitialTurnTick(clock, 0, 235_265_319)).toBeGreaterThanOrEqual(0);
});
test('uses explicit MANUAL and immediate RUNNING phases', () => {
const now = new Date('2030-01-01T01:00:00.000Z');
expect(resolveInitialClockPhase('manual', now, new Date('2030-01-02T01:00:00.000Z'))).toBe('MANUAL');
expect(resolveInitialClockPhase('realtime', now, now)).toBe('RUNNING');
});
});
@@ -1,6 +1,14 @@
import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
import type { GamePrisma } from '@sammo-ts/infra';
import { GameClock, asRecord, type GameClockMode } from '@sammo-ts/common';
import {
GameClock,
asObservedGameInstant,
asRecord,
inferClockPhase,
parseGameClockPhase,
scheduleNotBefore,
type GameClockMode,
} from '@sammo-ts/common';
export interface AdminSeedUser {
id: string;
@@ -105,14 +113,22 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse
const name = await resolveAdminName(prisma, adminUser);
const meta = asRecord(worldState.meta);
const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null;
const turnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date();
const fallbackTurnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date();
const mode = worldState.clockMode === 'manual' ? 'manual' : 'realtime';
const phase = worldState.clockPhase
? parseGameClockPhase(worldState.clockPhase)
: inferClockPhase(mode);
const gameClock = new GameClock({
baseTime: worldState.clockBaseTime ?? turnTime,
baseTime: worldState.clockBaseTime ?? fallbackTurnTime,
tick: Number(worldState.clockTick ?? 0n),
mode: worldState.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: worldState.clockWallAnchor ?? turnTime,
mode,
wallAnchor: worldState.clockWallAnchor ?? fallbackTurnTime,
turnSeconds: worldState.tickSeconds,
phase,
revision: Number(worldState.clockRevision ?? 1n),
});
const turnTick = scheduleNotBefore(asObservedGameInstant(gameClock.nowTick(new Date())), phase);
const turnTime = gameClock.tickToDate(turnTick);
await prisma.general.create({
data: {
@@ -130,7 +146,7 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse
specialCode: 'None',
special2Code: 'None',
turnTime,
turnTick: BigInt(gameClock.dateToTick(turnTime)),
turnTick: BigInt(turnTick),
meta: {
createdBy: 'admin-seed',
killturn: 24,
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
gameSchemaHead: '20260824080000_vote_utc_wall_timestamps',
gameSchemaHead: '20260903090000_add_game_clock_reconciliation',
});
});