feat: 게임 시계 reconciliation 권위 기반 추가
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user