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
+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: {