feat: 통일 대기 시계를 원자적 reconciliation으로 전환
This commit is contained in:
@@ -112,8 +112,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
|
||||
});
|
||||
const gameplayAllowed =
|
||||
!world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
|
||||
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
|
||||
const currentRevision = world?.clockRevision ?? null;
|
||||
const rows = await transaction.$queryRaw<
|
||||
Array<{
|
||||
@@ -139,7 +138,28 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
FROM "input_event"
|
||||
WHERE "target" = 'ENGINE'::"InputEventTarget"
|
||||
AND "status" = 'PENDING'::"InputEventStatus"
|
||||
AND (${gameplayAllowed} OR "event_type" = 'getStatus')
|
||||
AND (
|
||||
${gameplayAllowed}
|
||||
OR "event_type" = 'getStatus'
|
||||
OR (
|
||||
${world?.clockPhase === 'SUSPENDED'}
|
||||
AND "event_type" = 'messageRespond'
|
||||
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "message" AS pending_message
|
||||
WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer
|
||||
AND pending_message."message" #>> '{option,action}' = 'raiseInvader'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "clock_suspension" AS active_suspension
|
||||
WHERE active_suspension."status" = 'SUSPENDED'
|
||||
AND active_suspension."source" = 'UNIFICATION_WAIT'
|
||||
AND active_suspension."source_revision" = ${currentRevision}
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY "sequence" ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT ${limit}
|
||||
|
||||
@@ -29,6 +29,12 @@ export interface TurnDaemonCommandHandler {
|
||||
|
||||
export interface TurnDaemonCommandExecutionContext {
|
||||
db?: GamePrisma.TransactionClient;
|
||||
clockOperationAuthority?: {
|
||||
kind: 'DAEMON';
|
||||
profileName: string;
|
||||
ownerId: string;
|
||||
fencingEpoch: bigint;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TurnDaemonCommandResponder {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ImmediateGeneralActionExecutor } from './reservedTurnHandler.js';
|
||||
import { buildCommandEnv } from './reservedTurnCommands.js';
|
||||
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
||||
import type { TurnEvent } from './types.js';
|
||||
import { reconcileClockSuspensionInTransaction, type ClockReconciliationResult } from './clockReconciliation.js';
|
||||
|
||||
type ActionableMessageType = 'scout' | 'raiseInvader';
|
||||
|
||||
@@ -251,6 +252,18 @@ const respondToRaiseInvader = async (options: {
|
||||
payload: MessagePayload;
|
||||
now: Date;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
clockOperationAuthority?: {
|
||||
kind: 'DAEMON';
|
||||
profileName: string;
|
||||
ownerId: string;
|
||||
fencingEpoch: bigint;
|
||||
};
|
||||
reconcileUnificationWait?: (input: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
profileName: string;
|
||||
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
||||
}) => Promise<ClockReconciliationResult>;
|
||||
}): Promise<ActionableMessageResponseResult> => {
|
||||
const { db, world, reservedTurns, actorId, response, row, payload, now } = options;
|
||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||
@@ -272,6 +285,21 @@ const respondToRaiseInvader = async (options: {
|
||||
if (!reservedTurns) {
|
||||
throw new Error('RaiseInvader message response requires the reserved-turn store.');
|
||||
}
|
||||
const suspensionId =
|
||||
typeof state.meta.unificationClockSuspensionId === 'string' ? state.meta.unificationClockSuspensionId : null;
|
||||
if (!suspensionId || !options.clockOperationAuthority) {
|
||||
throw new Error('RaiseInvader requires a daemon-authorized UNIFICATION_WAIT suspension.');
|
||||
}
|
||||
const reconcile =
|
||||
options.reconcileUnificationWait ??
|
||||
((input) => reconcileClockSuspensionInTransaction({ ...input, allowUnificationWait: true }));
|
||||
const alignment = await reconcile({
|
||||
db,
|
||||
suspensionId,
|
||||
profileName: options.clockOperationAuthority.profileName,
|
||||
authority: options.clockOperationAuthority,
|
||||
});
|
||||
world.applyClockReconciliation(alignment);
|
||||
const args = asRecord(payload.option).args;
|
||||
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
|
||||
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
|
||||
@@ -281,6 +309,7 @@ const respondToRaiseInvader = async (options: {
|
||||
reservedTurns,
|
||||
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
|
||||
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
||||
clockWallNow: alignment.resumeWallAt,
|
||||
});
|
||||
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
|
||||
await handler(
|
||||
@@ -311,6 +340,18 @@ export const respondToActionableMessage = async (options: {
|
||||
messageId: number;
|
||||
response: boolean;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
clockOperationAuthority?: {
|
||||
kind: 'DAEMON';
|
||||
profileName: string;
|
||||
ownerId: string;
|
||||
fencingEpoch: bigint;
|
||||
};
|
||||
reconcileUnificationWait?: (input: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
profileName: string;
|
||||
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
||||
}) => Promise<ClockReconciliationResult>;
|
||||
}): Promise<ActionableMessageResponseResult> => {
|
||||
const acceptedAt = await validateActor(options);
|
||||
const now = options.world.getGameNow(acceptedAt);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type GamePrismaClient,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
interface ClockProjectionRedis {
|
||||
export interface ClockProjectionRedis {
|
||||
get(key: string): Promise<string | null>;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
}
|
||||
@@ -128,10 +128,7 @@ const parsePayload = (value: GamePrisma.JsonValue): ProjectionPayload => {
|
||||
targetRevision: safeInteger(payload.targetRevision, 'targetRevision'),
|
||||
deadlineGeneration: safeInteger(payload.deadlineGeneration, 'deadlineGeneration'),
|
||||
shiftTicks: safeInteger(payload.shiftTicks, 'shiftTicks'),
|
||||
projectionDeltaMilliseconds: safeInteger(
|
||||
payload.projectionDeltaMilliseconds,
|
||||
'projectionDeltaMilliseconds'
|
||||
),
|
||||
projectionDeltaMilliseconds: safeInteger(payload.projectionDeltaMilliseconds, 'projectionDeltaMilliseconds'),
|
||||
clockBaseTime: payload.clockBaseTime,
|
||||
ticksPerSecond: safeInteger(payload.ticksPerSecond, 'ticksPerSecond'),
|
||||
};
|
||||
@@ -284,7 +281,8 @@ export const applyNextClockProjection = async (options: {
|
||||
if (applied === -1) throw new Error('Redis active clock revision does not match the outbox source revision.');
|
||||
if (applied === -2) throw new Error('Redis tournament state changed while rebuilding its projection.');
|
||||
if (applied === -3) throw new Error('Redis target revision exists without the expected projection checksum.');
|
||||
if (applied !== 1 && applied !== 2) throw new Error(`Unexpected Redis clock projection result: ${String(result)}`);
|
||||
if (applied !== 1 && applied !== 2)
|
||||
throw new Error(`Unexpected Redis clock projection result: ${String(result)}`);
|
||||
|
||||
await options.db.$transaction(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
|
||||
@@ -111,10 +111,9 @@ const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
|
||||
return wallNow;
|
||||
};
|
||||
|
||||
const verifyAuthority = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
authority: ClockOperationAuthority
|
||||
): Promise<void> => {
|
||||
export const readClockDatabaseWall = readDbWall;
|
||||
|
||||
const verifyAuthority = async (db: GamePrisma.TransactionClient, authority: ClockOperationAuthority): Promise<void> => {
|
||||
const rows = await db.$queryRaw<LeaseFenceRow[]>(GamePrisma.sql`
|
||||
SELECT owner_id AS "ownerId",
|
||||
fencing_epoch AS "fencingEpoch",
|
||||
@@ -133,11 +132,7 @@ const verifyAuthority = async (
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!lease?.valid ||
|
||||
lease.ownerId !== authority.ownerId ||
|
||||
lease.fencingEpoch !== authority.fencingEpoch
|
||||
) {
|
||||
if (!lease?.valid || lease.ownerId !== authority.ownerId || lease.fencingEpoch !== authority.fencingEpoch) {
|
||||
throw new Error(`Stale turn-daemon fencing authority for profile ${authority.profileName}.`);
|
||||
}
|
||||
};
|
||||
@@ -319,6 +314,89 @@ const persistInitialParticipants = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Locks every registered participant before an enclosing transaction mutates
|
||||
* the world into a suspended state. The caller must already hold the daemon,
|
||||
* clock-operation, general-access, and world-row lock prefix.
|
||||
*/
|
||||
export const prepareClockSuspensionUnderHeldLocks = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
cutTick: number;
|
||||
cutWallAt?: Date;
|
||||
}): Promise<{ cutWallAt: Date }> => {
|
||||
if (!Number.isSafeInteger(options.cutTick)) {
|
||||
throw new Error(`Clock suspension cut tick is outside the safe integer range: ${options.cutTick}.`);
|
||||
}
|
||||
const cutWallAt = options.cutWallAt ? new Date(options.cutWallAt.getTime()) : await readDbWall(options.db);
|
||||
await lockParticipants(options.db, BigInt(options.cutTick));
|
||||
return { cutWallAt };
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists the ledger after all suspension-boundary gameplay writes have been
|
||||
* staged in the same transaction. Lock acquisition belongs to
|
||||
* prepareClockSuspensionUnderHeldLocks and must happen first.
|
||||
*/
|
||||
export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
worldStateId: number;
|
||||
profileName: string;
|
||||
source: ClockSuspensionSource;
|
||||
cutTick: number;
|
||||
cutWallAt: Date;
|
||||
rateTicksPerSecond: number;
|
||||
sourceRevision: number;
|
||||
policy?: ClockAlignmentPolicy;
|
||||
catchUpTicks?: number;
|
||||
}): Promise<void> => {
|
||||
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
|
||||
throw new Error('Clock suspension ID must contain 1-64 characters.');
|
||||
}
|
||||
const policy = options.policy ?? 'EXACT';
|
||||
const catchUpTicks = options.catchUpTicks ?? 0;
|
||||
if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) {
|
||||
throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.');
|
||||
}
|
||||
const existing = await options.db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
|
||||
if (existing) {
|
||||
if (
|
||||
existing.worldStateId !== options.worldStateId ||
|
||||
existing.source !== options.source ||
|
||||
existing.sourceRevision !== BigInt(options.sourceRevision)
|
||||
) {
|
||||
throw new Error(`Clock suspension ID ${options.suspensionId} is already bound to another operation.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const world = await options.db.worldState.findUniqueOrThrow({ where: { id: options.worldStateId } });
|
||||
if (
|
||||
parseGameClockPhase(world.clockPhase) !== 'SUSPENDED' ||
|
||||
world.clockRevision !== BigInt(options.sourceRevision)
|
||||
) {
|
||||
throw new Error('Clock suspension ledger requires a matching durable SUSPENDED world revision.');
|
||||
}
|
||||
const participants = await readParticipantSnapshots(options.db, options.worldStateId, BigInt(options.cutTick));
|
||||
await options.db.clockSuspension.create({
|
||||
data: {
|
||||
id: options.suspensionId,
|
||||
worldStateId: options.worldStateId,
|
||||
source: options.source,
|
||||
policy,
|
||||
status: 'SUSPENDED',
|
||||
sourceRevision: BigInt(options.sourceRevision),
|
||||
targetRevision: BigInt(options.sourceRevision + 1),
|
||||
cutTick: BigInt(options.cutTick),
|
||||
cutWallAt: options.cutWallAt,
|
||||
rateTicksPerSecond: options.rateTicksPerSecond,
|
||||
catchUpTicks: BigInt(catchUpTicks),
|
||||
participantChecksumBefore: aggregateChecksum(participants),
|
||||
detail: asJson({ authority: 'DAEMON', profileName: options.profileName }),
|
||||
},
|
||||
});
|
||||
await persistInitialParticipants(options.db, options.suspensionId, participants);
|
||||
};
|
||||
|
||||
const shiftMetaDate = (value: unknown, deltaMilliseconds: number): unknown => {
|
||||
if (typeof value !== 'string' || !value.trim()) return value;
|
||||
const parsed = new Date(value);
|
||||
@@ -512,8 +590,14 @@ export const startClockSuspension = async (options: {
|
||||
const worldStateId = await lockWorld(db);
|
||||
const existing = await db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
|
||||
if (existing) {
|
||||
if (existing.worldStateId !== worldStateId || existing.source !== options.source || existing.policy !== policy) {
|
||||
throw new Error(`Clock suspension ID ${options.suspensionId} is already bound to another operation.`);
|
||||
if (
|
||||
existing.worldStateId !== worldStateId ||
|
||||
existing.source !== options.source ||
|
||||
existing.policy !== policy
|
||||
) {
|
||||
throw new Error(
|
||||
`Clock suspension ID ${options.suspensionId} is already bound to another operation.`
|
||||
);
|
||||
}
|
||||
if (existing.status !== 'SUSPENDED') {
|
||||
throw new Error(`Clock suspension ${options.suspensionId} already advanced to ${existing.status}.`);
|
||||
@@ -585,6 +669,209 @@ export const startClockSuspension = async (options: {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Continues a suspension inside a transaction whose caller already verified
|
||||
* daemon authority and acquired the clock/general-access lock prefix.
|
||||
*/
|
||||
export const reconcileClockSuspensionInTransaction = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
profileName: string;
|
||||
allowUnificationWait?: boolean;
|
||||
authority?: ClockOperationAuthority;
|
||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
||||
testResumeWallAt?: Date;
|
||||
}): Promise<ClockReconciliationResult> => {
|
||||
const db = options.db;
|
||||
const worldStateId = await lockWorld(db);
|
||||
const suspension = await db.clockSuspension.findUniqueOrThrow({ where: { id: options.suspensionId } });
|
||||
if (suspension.worldStateId !== worldStateId) {
|
||||
throw new Error('Clock suspension belongs to another world state.');
|
||||
}
|
||||
if (suspension.status === 'RECONCILING' || suspension.status === 'APPLIED') {
|
||||
if (
|
||||
suspension.gapTicks === null ||
|
||||
suspension.shiftTicks === null ||
|
||||
suspension.alignedTick === null ||
|
||||
!suspension.resumeWallAt
|
||||
) {
|
||||
throw new Error('Persisted clock reconciliation result is incomplete.');
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
return {
|
||||
suspensionId: suspension.id,
|
||||
phase: 'RECONCILING' as const,
|
||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||
targetRevision: safeNumber(suspension.targetRevision, 'target revision'),
|
||||
deadlineGeneration: safeNumber(world.deadlineGeneration, 'deadline generation'),
|
||||
gapTicks: safeNumber(suspension.gapTicks, 'gap ticks'),
|
||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
||||
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
|
||||
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
|
||||
resumeWallAt: suspension.resumeWallAt,
|
||||
};
|
||||
}
|
||||
if (suspension.status !== 'SUSPENDED') {
|
||||
throw new Error(`Clock suspension cannot reconcile from status ${suspension.status}.`);
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
if (phase !== 'SUSPENDED' || world.clockRevision !== suspension.sourceRevision) {
|
||||
throw new Error('Clock reconciliation phase or source revision fence failed.');
|
||||
}
|
||||
const worldMeta =
|
||||
world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta)
|
||||
? (world.meta as Record<string, unknown>)
|
||||
: {};
|
||||
const united = Number(worldMeta.isunited ?? worldMeta.isUnited ?? 0);
|
||||
if (suspension.source === 'UNIFICATION_WAIT' || united >= 2) {
|
||||
if (!options.allowUnificationWait || options.authority?.kind !== 'DAEMON') {
|
||||
throw new Error('Unification wait requires the daemon-authorized atomic alignment-and-invader workflow.');
|
||||
}
|
||||
await verifyAuthority(db, options.authority);
|
||||
}
|
||||
const cutTick = safeNumber(suspension.cutTick, 'cut tick');
|
||||
await lockParticipants(db, suspension.cutTick);
|
||||
if (options.testResumeWallAt && process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('A clock reconciliation wall override is allowed only in tests.');
|
||||
}
|
||||
const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db);
|
||||
const plan = buildClockAlignmentPlan({
|
||||
policy: parseClockAlignmentPolicy(suspension.policy),
|
||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||
cutTick,
|
||||
cutWall: suspension.cutWallAt,
|
||||
resumeWall: resumeWallAt,
|
||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
||||
});
|
||||
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
assertShiftFits(before, plan.shiftTicks);
|
||||
await assertScheduleRanges(db, plan.shiftTicks);
|
||||
const projectionDeltaMilliseconds = Math.trunc((plan.shiftTicks * 1_000) / suspension.rateTicksPerSecond);
|
||||
if (!Number.isSafeInteger(projectionDeltaMilliseconds)) {
|
||||
throw new Error('Clock reconciliation projection delta is outside the safe integer range.');
|
||||
}
|
||||
const targetGeneration = world.deadlineGeneration + 1n;
|
||||
const affected = await applyParticipantShift(
|
||||
db,
|
||||
worldStateId,
|
||||
suspension.cutTick,
|
||||
BigInt(plan.alignedTick),
|
||||
BigInt(plan.targetRevision),
|
||||
targetGeneration,
|
||||
BigInt(plan.shiftTicks),
|
||||
projectionDeltaMilliseconds,
|
||||
resumeWallAt
|
||||
);
|
||||
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
|
||||
for (const participant of before) {
|
||||
const next = afterByKey.get(participant.key);
|
||||
if (!next) throw new Error(`Missing post-reconciliation participant: ${participant.key}`);
|
||||
if (participant.policy === 'KEEP' && participant.checksum !== next.checksum) {
|
||||
throw new Error(`KEEP participant changed during reconciliation: ${participant.key}`);
|
||||
}
|
||||
await db.clockReconciliationParticipant.upsert({
|
||||
where: {
|
||||
suspensionId_participantKey: {
|
||||
suspensionId: suspension.id,
|
||||
participantKey: participant.key,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
suspensionId: suspension.id,
|
||||
participantKey: participant.key,
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: next.checksum,
|
||||
affectedCount: affected.get(participant.key) ?? 0,
|
||||
},
|
||||
update: {
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: next.checksum,
|
||||
affectedCount: affected.get(participant.key) ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
const outboxPayload = {
|
||||
version: 1,
|
||||
profileName: options.profileName,
|
||||
suspensionId: suspension.id,
|
||||
sourceRevision: plan.sourceRevision,
|
||||
targetRevision: plan.targetRevision,
|
||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
||||
shiftTicks: plan.shiftTicks,
|
||||
projectionDeltaMilliseconds,
|
||||
clockBaseTime: world.clockBaseTime!.toISOString(),
|
||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||
};
|
||||
await db.clockProjectionOutbox.create({
|
||||
data: {
|
||||
worldStateId,
|
||||
suspensionId: suspension.id,
|
||||
targetRevision: BigInt(plan.targetRevision),
|
||||
status: 'PENDING',
|
||||
payload: asJson(outboxPayload),
|
||||
checksum: checksum(outboxPayload),
|
||||
},
|
||||
});
|
||||
await db.clockSuspension.update({
|
||||
where: { id: suspension.id },
|
||||
data: {
|
||||
status: 'RECONCILING',
|
||||
resumeWallAt,
|
||||
gapTicks: BigInt(plan.gapTicks),
|
||||
shiftTicks: BigInt(plan.shiftTicks),
|
||||
alignedTick: BigInt(plan.alignedTick),
|
||||
participantChecksumBefore: aggregateChecksum(before),
|
||||
participantChecksumAfter: aggregateChecksum(after),
|
||||
},
|
||||
});
|
||||
return {
|
||||
suspensionId: suspension.id,
|
||||
phase: 'RECONCILING',
|
||||
sourceRevision: plan.sourceRevision,
|
||||
targetRevision: plan.targetRevision,
|
||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
||||
gapTicks: plan.gapTicks,
|
||||
catchUpTicks: plan.catchUpTicks,
|
||||
shiftTicks: plan.shiftTicks,
|
||||
alignedTick: plan.alignedTick,
|
||||
resumeWallAt,
|
||||
};
|
||||
};
|
||||
|
||||
/** Finalizes an atomic unification workflow after an optional rate change. */
|
||||
export const refreshClockProjectionForFinalClockUnderHeldLocks = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
suspensionId: string;
|
||||
clockBaseTime: Date;
|
||||
tickSeconds: number;
|
||||
}): Promise<void> => {
|
||||
if (GAME_TICKS_PER_TURN % options.tickSeconds !== 0) {
|
||||
throw new Error(`Final clock rate cannot represent an integer tick: ${options.tickSeconds}.`);
|
||||
}
|
||||
const outbox = await options.db.clockProjectionOutbox.findFirstOrThrow({
|
||||
where: { suspensionId: options.suspensionId, status: 'PENDING' },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const payload =
|
||||
outbox.payload && typeof outbox.payload === 'object' && !Array.isArray(outbox.payload)
|
||||
? { ...(outbox.payload as Record<string, unknown>) }
|
||||
: null;
|
||||
if (!payload || payload.suspensionId !== options.suspensionId) {
|
||||
throw new Error('Unification clock projection outbox payload is invalid.');
|
||||
}
|
||||
payload.clockBaseTime = options.clockBaseTime.toISOString();
|
||||
payload.ticksPerSecond = GAME_TICKS_PER_TURN / options.tickSeconds;
|
||||
await options.db.clockProjectionOutbox.update({
|
||||
where: { id: outbox.id },
|
||||
data: { payload: asJson(payload), checksum: checksum(payload) },
|
||||
});
|
||||
};
|
||||
|
||||
export const reconcileClockSuspension = async (options: {
|
||||
db: GamePrismaClient;
|
||||
suspensionId: string;
|
||||
@@ -597,167 +884,13 @@ export const reconcileClockSuspension = async (options: {
|
||||
await verifyAuthority(db, options.authority);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
const worldStateId = await lockWorld(db);
|
||||
const suspension = await db.clockSuspension.findUniqueOrThrow({ where: { id: options.suspensionId } });
|
||||
if (suspension.worldStateId !== worldStateId) {
|
||||
throw new Error('Clock suspension belongs to another world state.');
|
||||
}
|
||||
if (suspension.status === 'RECONCILING' || suspension.status === 'APPLIED') {
|
||||
if (
|
||||
suspension.gapTicks === null ||
|
||||
suspension.shiftTicks === null ||
|
||||
suspension.alignedTick === null ||
|
||||
!suspension.resumeWallAt
|
||||
) {
|
||||
throw new Error('Persisted clock reconciliation result is incomplete.');
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
return {
|
||||
suspensionId: suspension.id,
|
||||
phase: 'RECONCILING' as const,
|
||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||
targetRevision: safeNumber(suspension.targetRevision, 'target revision'),
|
||||
deadlineGeneration: safeNumber(world.deadlineGeneration, 'deadline generation'),
|
||||
gapTicks: safeNumber(suspension.gapTicks, 'gap ticks'),
|
||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
||||
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
|
||||
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
|
||||
resumeWallAt: suspension.resumeWallAt,
|
||||
};
|
||||
}
|
||||
if (suspension.status !== 'SUSPENDED') {
|
||||
throw new Error(`Clock suspension cannot reconcile from status ${suspension.status}.`);
|
||||
}
|
||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
if (phase !== 'SUSPENDED' || world.clockRevision !== suspension.sourceRevision) {
|
||||
throw new Error('Clock reconciliation phase or source revision fence failed.');
|
||||
}
|
||||
const worldMeta =
|
||||
world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta)
|
||||
? (world.meta as Record<string, unknown>)
|
||||
: {};
|
||||
const united = Number(worldMeta.isunited ?? worldMeta.isUnited ?? 0);
|
||||
if (suspension.source === 'UNIFICATION_WAIT' || united >= 2) {
|
||||
throw new Error(
|
||||
'Unification wait requires the atomic alignment-and-invader workflow; generic resume is forbidden.'
|
||||
);
|
||||
}
|
||||
const cutTick = safeNumber(suspension.cutTick, 'cut tick');
|
||||
await lockParticipants(db, suspension.cutTick);
|
||||
if (options.testResumeWallAt && process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('A clock reconciliation wall override is allowed only in tests.');
|
||||
}
|
||||
const resumeWallAt = options.testResumeWallAt
|
||||
? new Date(options.testResumeWallAt.getTime())
|
||||
: await readDbWall(db);
|
||||
const plan = buildClockAlignmentPlan({
|
||||
policy: parseClockAlignmentPolicy(suspension.policy),
|
||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||
cutTick,
|
||||
cutWall: suspension.cutWallAt,
|
||||
resumeWall: resumeWallAt,
|
||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
||||
});
|
||||
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
assertShiftFits(before, plan.shiftTicks);
|
||||
await assertScheduleRanges(db, plan.shiftTicks);
|
||||
const projectionDeltaMilliseconds = Math.trunc(
|
||||
(plan.shiftTicks * 1_000) / suspension.rateTicksPerSecond
|
||||
);
|
||||
if (!Number.isSafeInteger(projectionDeltaMilliseconds)) {
|
||||
throw new Error('Clock reconciliation projection delta is outside the safe integer range.');
|
||||
}
|
||||
const targetGeneration = world.deadlineGeneration + 1n;
|
||||
const affected = await applyParticipantShift(
|
||||
return reconcileClockSuspensionInTransaction({
|
||||
db,
|
||||
worldStateId,
|
||||
suspension.cutTick,
|
||||
BigInt(plan.alignedTick),
|
||||
BigInt(plan.targetRevision),
|
||||
targetGeneration,
|
||||
BigInt(plan.shiftTicks),
|
||||
projectionDeltaMilliseconds,
|
||||
resumeWallAt
|
||||
);
|
||||
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
|
||||
for (const participant of before) {
|
||||
const next = afterByKey.get(participant.key);
|
||||
if (!next) throw new Error(`Missing post-reconciliation participant: ${participant.key}`);
|
||||
if (participant.policy === 'KEEP' && participant.checksum !== next.checksum) {
|
||||
throw new Error(`KEEP participant changed during reconciliation: ${participant.key}`);
|
||||
}
|
||||
await db.clockReconciliationParticipant.upsert({
|
||||
where: {
|
||||
suspensionId_participantKey: {
|
||||
suspensionId: suspension.id,
|
||||
participantKey: participant.key,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
suspensionId: suspension.id,
|
||||
participantKey: participant.key,
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: next.checksum,
|
||||
affectedCount: affected.get(participant.key) ?? 0,
|
||||
},
|
||||
update: {
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.checksum,
|
||||
afterChecksum: next.checksum,
|
||||
affectedCount: affected.get(participant.key) ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
const outboxPayload = {
|
||||
version: 1,
|
||||
suspensionId: options.suspensionId,
|
||||
profileName: options.authority.profileName,
|
||||
suspensionId: suspension.id,
|
||||
sourceRevision: plan.sourceRevision,
|
||||
targetRevision: plan.targetRevision,
|
||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
||||
shiftTicks: plan.shiftTicks,
|
||||
projectionDeltaMilliseconds,
|
||||
clockBaseTime: world.clockBaseTime!.toISOString(),
|
||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||
};
|
||||
await db.clockProjectionOutbox.create({
|
||||
data: {
|
||||
worldStateId,
|
||||
suspensionId: suspension.id,
|
||||
targetRevision: BigInt(plan.targetRevision),
|
||||
status: 'PENDING',
|
||||
payload: asJson(outboxPayload),
|
||||
checksum: checksum(outboxPayload),
|
||||
},
|
||||
authority: options.authority,
|
||||
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
|
||||
});
|
||||
await db.clockSuspension.update({
|
||||
where: { id: suspension.id },
|
||||
data: {
|
||||
status: 'RECONCILING',
|
||||
resumeWallAt,
|
||||
gapTicks: BigInt(plan.gapTicks),
|
||||
shiftTicks: BigInt(plan.shiftTicks),
|
||||
alignedTick: BigInt(plan.alignedTick),
|
||||
participantChecksumBefore: aggregateChecksum(before),
|
||||
participantChecksumAfter: aggregateChecksum(after),
|
||||
},
|
||||
});
|
||||
return {
|
||||
suspensionId: suspension.id,
|
||||
phase: 'RECONCILING',
|
||||
sourceRevision: plan.sourceRevision,
|
||||
targetRevision: plan.targetRevision,
|
||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
||||
gapTicks: plan.gapTicks,
|
||||
catchUpTicks: plan.catchUpTicks,
|
||||
shiftTicks: plan.shiftTicks,
|
||||
alignedTick: plan.alignedTick,
|
||||
resumeWallAt,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
);
|
||||
|
||||
@@ -56,12 +56,20 @@ import { persistUnificationFinalization } from './unificationPersistence.js';
|
||||
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||
import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
||||
import { buildTurnWebPushEvents, captureWebPushTurnBaseline } from './webPushEvents.js';
|
||||
import {
|
||||
persistClockSuspensionLedgerUnderHeldLocks,
|
||||
prepareClockSuspensionUnderHeldLocks,
|
||||
readClockDatabaseWall,
|
||||
refreshClockProjectionForFinalClockUnderHeldLocks,
|
||||
} from './clockReconciliation.js';
|
||||
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
|
||||
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
|
||||
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
|
||||
close(): Promise<void>;
|
||||
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface CommittedReadModelChangeReceipt {
|
||||
@@ -1140,8 +1148,7 @@ export const createDatabaseTurnHooks = async (
|
||||
lifecycleEvents.length > 0 ||
|
||||
deletedGenerals.length > 0 ||
|
||||
generals.some(
|
||||
(general) =>
|
||||
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
|
||||
(general) => typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
|
||||
);
|
||||
const persist = async (
|
||||
prisma: GamePrisma.TransactionClient
|
||||
@@ -1204,12 +1211,27 @@ export const createDatabaseTurnHooks = async (
|
||||
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
|
||||
const expectedRevision = BigInt(state.clockRevision ?? 1);
|
||||
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
|
||||
const stateMeta = asRecord(state.meta);
|
||||
const unificationSuspensionId =
|
||||
typeof stateMeta.unificationClockSuspensionId === 'string'
|
||||
? stateMeta.unificationClockSuspensionId
|
||||
: null;
|
||||
const openingPhaseTransition =
|
||||
durableClock.clock_phase === 'PREOPEN' &&
|
||||
expectedPhase === 'RUNNING' &&
|
||||
durableClock.opening_reached;
|
||||
durableClock.clock_phase === 'PREOPEN' && expectedPhase === 'RUNNING' && durableClock.opening_reached;
|
||||
const unificationSuspensionTransition =
|
||||
durableClock.clock_phase === 'RUNNING' &&
|
||||
expectedPhase === 'SUSPENDED' &&
|
||||
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) === 2 &&
|
||||
Boolean(unificationSuspensionId);
|
||||
const completionPhaseTransition =
|
||||
durableClock.clock_phase === 'RUNNING' &&
|
||||
expectedPhase === 'COMPLETED' &&
|
||||
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) >= 2;
|
||||
if (
|
||||
(!openingPhaseTransition && durableClock.clock_phase !== expectedPhase) ||
|
||||
(!openingPhaseTransition &&
|
||||
!unificationSuspensionTransition &&
|
||||
!completionPhaseTransition &&
|
||||
durableClock.clock_phase !== expectedPhase) ||
|
||||
durableClock.clock_revision !== expectedRevision ||
|
||||
durableClock.deadline_generation !== expectedGeneration
|
||||
) {
|
||||
@@ -1218,6 +1240,18 @@ export const createDatabaseTurnHooks = async (
|
||||
`found ${durableClock.clock_phase}@${durableClock.clock_revision}/${durableClock.deadline_generation}.`
|
||||
);
|
||||
}
|
||||
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
|
||||
const unificationCutTick = unificationCutWallAt
|
||||
? world.dateToGameTick(world.getGameNow(unificationCutWallAt))
|
||||
: null;
|
||||
const suspensionPreparation =
|
||||
unificationCutTick !== null
|
||||
? await prepareClockSuspensionUnderHeldLocks({
|
||||
db: prisma,
|
||||
cutTick: unificationCutTick,
|
||||
cutWallAt: unificationCutWallAt!,
|
||||
})
|
||||
: null;
|
||||
if (commandCompletion) {
|
||||
const commandFence = await prisma.$queryRaw<
|
||||
Array<{
|
||||
@@ -1235,11 +1269,21 @@ export const createDatabaseTurnHooks = async (
|
||||
FOR UPDATE
|
||||
`);
|
||||
const event = commandFence[0];
|
||||
const unificationRevisionTransition =
|
||||
commandCompletion.result.type === 'messageRespond' &&
|
||||
commandCompletion.result.ok &&
|
||||
commandCompletion.result.action === 'raiseInvader' &&
|
||||
expectedPhase === 'RECONCILING' &&
|
||||
event?.processing_clock_revision !== null &&
|
||||
event?.processing_deadline_generation !== null &&
|
||||
event?.processing_clock_revision + 1n === expectedRevision &&
|
||||
event?.processing_deadline_generation + 1n === expectedGeneration;
|
||||
if (
|
||||
!event ||
|
||||
event.status !== 'PROCESSING' ||
|
||||
event.processing_clock_revision !== expectedRevision ||
|
||||
event.processing_deadline_generation !== expectedGeneration
|
||||
(!unificationRevisionTransition &&
|
||||
(event.processing_clock_revision !== expectedRevision ||
|
||||
event.processing_deadline_generation !== expectedGeneration))
|
||||
) {
|
||||
throw new Error(
|
||||
`Input event processing clock fence changed before commit: ${commandCompletion.requestId}.`
|
||||
@@ -1350,6 +1394,35 @@ export const createDatabaseTurnHooks = async (
|
||||
END
|
||||
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL
|
||||
`);
|
||||
await prisma.$executeRaw(GamePrisma.sql`
|
||||
UPDATE select_pool
|
||||
SET reserved_until = CASE
|
||||
WHEN reserved_until_tick IS NULL THEN reserved_until
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (reserved_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((reserved_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
|
||||
* INTERVAL '1 millisecond'
|
||||
END
|
||||
WHERE reserved_until_tick IS NOT NULL
|
||||
`);
|
||||
await prisma.$executeRaw(GamePrisma.sql`
|
||||
UPDATE select_npc_token
|
||||
SET valid_until = CASE
|
||||
WHEN valid_until_tick IS NULL THEN valid_until
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
|
||||
* INTERVAL '1 millisecond'
|
||||
END,
|
||||
pick_more_from = CASE
|
||||
WHEN pick_more_from_tick IS NULL THEN pick_more_from
|
||||
ELSE CAST(${baseTime} AS timestamp)
|
||||
+ (pick_more_from_tick / ${ticksPerSecond}) * INTERVAL '1 second'
|
||||
+ (((pick_more_from_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
|
||||
* INTERVAL '1 millisecond'
|
||||
END
|
||||
WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
for (const betting of pendingNationBettingOpens) {
|
||||
@@ -1817,6 +1890,41 @@ export const createDatabaseTurnHooks = async (
|
||||
if (options?.reservedTurns && persistedReservedTurnChanges) {
|
||||
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
|
||||
}
|
||||
if (suspensionPreparation && unificationSuspensionId) {
|
||||
const cutTick = unificationCutTick!;
|
||||
await prisma.worldState.update({
|
||||
where: { id: state.id },
|
||||
data: {
|
||||
clockTick: BigInt(cutTick),
|
||||
clockWallAnchor: suspensionPreparation.cutWallAt,
|
||||
},
|
||||
});
|
||||
await persistClockSuspensionLedgerUnderHeldLocks({
|
||||
db: prisma,
|
||||
suspensionId: unificationSuspensionId,
|
||||
worldStateId: state.id,
|
||||
profileName: options?.profileName ?? 'default',
|
||||
source: 'UNIFICATION_WAIT',
|
||||
cutTick,
|
||||
cutWallAt: suspensionPreparation.cutWallAt,
|
||||
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
|
||||
sourceRevision: state.clockRevision ?? 1,
|
||||
});
|
||||
}
|
||||
if (
|
||||
commandCompletion?.result.type === 'messageRespond' &&
|
||||
commandCompletion.result.ok &&
|
||||
commandCompletion.result.action === 'raiseInvader' &&
|
||||
unificationSuspensionId &&
|
||||
state.clockPhase === 'RECONCILING'
|
||||
) {
|
||||
await refreshClockProjectionForFinalClockUnderHeldLocks({
|
||||
db: prisma,
|
||||
suspensionId: unificationSuspensionId,
|
||||
clockBaseTime: state.clockBaseTime ?? state.lastTurnTime,
|
||||
tickSeconds: state.tickSeconds,
|
||||
});
|
||||
}
|
||||
if (commandCompletion) {
|
||||
await prisma.inputEvent.update({
|
||||
where: { requestId: commandCompletion.requestId },
|
||||
@@ -1902,6 +2010,10 @@ export const createDatabaseTurnHooks = async (
|
||||
},
|
||||
executeCommand: async (requestId, execute) => {
|
||||
const committed = await prisma.$transaction(async (transaction) => {
|
||||
await options?.turnDaemonLease?.assertActive(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
const leaseToken = options?.turnDaemonLease?.getToken();
|
||||
const directLogFloor =
|
||||
(
|
||||
await transaction.logEntry.findFirst({
|
||||
@@ -1909,7 +2021,19 @@ export const createDatabaseTurnHooks = async (
|
||||
select: { id: true },
|
||||
})
|
||||
)?.id ?? 0;
|
||||
const result = await execute({ db: transaction });
|
||||
const result = await execute({
|
||||
db: transaction,
|
||||
...(leaseToken
|
||||
? {
|
||||
clockOperationAuthority: {
|
||||
kind: 'DAEMON' as const,
|
||||
profileName: leaseToken.profile,
|
||||
ownerId: leaseToken.ownerId,
|
||||
fencingEpoch: leaseToken.fencingEpoch,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor);
|
||||
return { result, persisted };
|
||||
}, transactionOptions);
|
||||
@@ -1925,6 +2049,14 @@ export const createDatabaseTurnHooks = async (
|
||||
return takeCommittedReceipt()?.changes ?? null;
|
||||
},
|
||||
takeCommittedReadModelChangeReceipt: takeCommittedReceipt,
|
||||
applyClockProjection: async (redis, workerId) => {
|
||||
await applyNextClockProjection({ db: prisma, redis, workerId });
|
||||
const clock = await prisma.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true },
|
||||
});
|
||||
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
|
||||
},
|
||||
close: () => connector.disconnect(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -696,6 +696,116 @@ export class InMemoryTurnWorld {
|
||||
return true;
|
||||
}
|
||||
|
||||
beginUnificationWait(suspensionId: string): void {
|
||||
const phase = this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual');
|
||||
if (phase === 'SUSPENDED' && this.state.meta.unificationClockSuspensionId === suspensionId) {
|
||||
return;
|
||||
}
|
||||
if (phase !== 'RUNNING') {
|
||||
throw new Error(`UNIFICATION_WAIT can start only from RUNNING; current phase is ${phase}.`);
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
clockPhase: 'SUSPENDED',
|
||||
meta: {
|
||||
...this.state.meta,
|
||||
unificationClockSuspensionId: suspensionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
completeGameClock(): void {
|
||||
const phase = this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual');
|
||||
if (phase === 'COMPLETED') return;
|
||||
if (phase !== 'RUNNING') {
|
||||
throw new Error(`Game clock can complete only from RUNNING; current phase is ${phase}.`);
|
||||
}
|
||||
this.state = { ...this.state, clockPhase: 'COMPLETED' };
|
||||
}
|
||||
|
||||
applyClockReconciliation(input: {
|
||||
suspensionId: string;
|
||||
alignedTick: number;
|
||||
shiftTicks: number;
|
||||
targetRevision: number;
|
||||
deadlineGeneration: number;
|
||||
resumeWallAt: Date;
|
||||
}): void {
|
||||
const phase = this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual');
|
||||
if (phase !== 'SUSPENDED' || this.state.meta.unificationClockSuspensionId !== input.suspensionId) {
|
||||
throw new Error('In-memory clock reconciliation requires the matching UNIFICATION_WAIT suspension.');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(input.alignedTick) ||
|
||||
!Number.isSafeInteger(input.shiftTicks) ||
|
||||
input.shiftTicks < 0 ||
|
||||
!Number.isSafeInteger(input.targetRevision) ||
|
||||
!Number.isSafeInteger(input.deadlineGeneration)
|
||||
) {
|
||||
throw new Error('In-memory clock reconciliation received an unsafe coordinate.');
|
||||
}
|
||||
const clock = this.getGameClock();
|
||||
const shiftedMilliseconds = Math.trunc((input.shiftTicks * 1_000) / clock.ticksPerSecond);
|
||||
if (!Number.isSafeInteger(shiftedMilliseconds)) {
|
||||
throw new Error('In-memory clock reconciliation projection delta is unsafe.');
|
||||
}
|
||||
const lastTurnTick = clock.addTicks(this.state.lastTurnTick ?? 0, input.shiftTicks);
|
||||
const lastTurnTime = clock.tickToDate(lastTurnTick);
|
||||
this.state = {
|
||||
...this.state,
|
||||
clockTick: input.alignedTick,
|
||||
clockWallAnchor: new Date(input.resumeWallAt.getTime()),
|
||||
lastTurnTick,
|
||||
lastTurnTime,
|
||||
clockPhase: 'RECONCILING',
|
||||
clockRevision: input.targetRevision,
|
||||
deadlineGeneration: input.deadlineGeneration,
|
||||
meta: {
|
||||
...this.state.meta,
|
||||
lastTurnTime: lastTurnTime.toISOString(),
|
||||
turntime: shiftGameClockMetaDate(this.state.meta.turntime, shiftedMilliseconds),
|
||||
starttime: shiftGameClockMetaDate(this.state.meta.starttime, shiftedMilliseconds),
|
||||
tnmt_time: shiftGameClockMetaDate(this.state.meta.tnmt_time, shiftedMilliseconds),
|
||||
},
|
||||
};
|
||||
for (const [generalId, general] of this.generals) {
|
||||
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks);
|
||||
this.generals.set(generalId, {
|
||||
...general,
|
||||
turnTick,
|
||||
turnTime: clock.tickToDate(turnTick),
|
||||
});
|
||||
}
|
||||
for (const entry of this.generalPoolEntries ?? []) {
|
||||
if (entry.reservedUntilTick !== null) {
|
||||
entry.reservedUntilTick = clock.addTicks(entry.reservedUntilTick, input.shiftTicks);
|
||||
entry.reservedUntil = clock.tickToDate(entry.reservedUntilTick);
|
||||
} else if (entry.reservedUntil) {
|
||||
entry.reservedUntil = new Date(entry.reservedUntil.getTime() + shiftedMilliseconds);
|
||||
}
|
||||
}
|
||||
for (const auction of this.pendingNeutralAuctions) {
|
||||
auction.closeAt = new Date(auction.closeAt.getTime() + shiftedMilliseconds);
|
||||
}
|
||||
if (this.checkpoint) {
|
||||
const checkpointTick = clock.addTicks(
|
||||
this.checkpoint.turnTick ?? clock.dateToTick(new Date(this.checkpoint.turnTime)),
|
||||
input.shiftTicks
|
||||
);
|
||||
this.checkpoint = {
|
||||
...this.checkpoint,
|
||||
turnTick: checkpointTick,
|
||||
turnTime: clock.tickToDate(checkpointTick).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
completeClockReconciliation(): void {
|
||||
if (this.state.clockPhase === 'RECONCILING') {
|
||||
this.state = { ...this.state, clockPhase: 'RUNNING' };
|
||||
}
|
||||
}
|
||||
|
||||
getRunnableGameNow(wallNow: Date): Date {
|
||||
const clock = this.getGameClock();
|
||||
// PREOPEN still needs negative game ticks for cooldowns, but executable
|
||||
|
||||
@@ -133,6 +133,7 @@ export const createRaiseInvaderHandler = (options: {
|
||||
env: TurnCommandEnv;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
maxGeneralsPerMinute?: number;
|
||||
clockWallNow?: Date;
|
||||
}): MonthlyEventActionHandler => {
|
||||
return async (args, environment) => {
|
||||
const world = options.getWorld();
|
||||
@@ -166,7 +167,7 @@ export const createRaiseInvaderHandler = (options: {
|
||||
(candidate) => totalGeneralCount <= maxGeneralsPerMinute * candidate
|
||||
);
|
||||
if (nextTerm !== undefined) {
|
||||
world.changeTurnTerm(nextTerm);
|
||||
world.changeTurnTerm(nextTerm, options.clockWallNow);
|
||||
// Reprojection preserves the frozen monthly boundary by tick but
|
||||
// changes its displayed Date. New generals must join that frozen
|
||||
// boundary, not the realtime game clock that kept advancing while
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
@@ -20,7 +22,11 @@ import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } fr
|
||||
import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js';
|
||||
import { DatabaseTurnDaemonCommandQueue } from '../lifecycle/databaseCommandQueue.js';
|
||||
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
||||
import { createDatabaseTurnHooks, type CommittedReadModelChangeReceipt } from './databaseHooks.js';
|
||||
import {
|
||||
createDatabaseTurnHooks,
|
||||
type CommittedReadModelChangeReceipt,
|
||||
type DatabaseTurnHooks,
|
||||
} from './databaseHooks.js';
|
||||
import type { GeneralTurnHandler, InMemoryTurnWorldOptions, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||
import { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js';
|
||||
@@ -497,15 +503,40 @@ const createRealtimeRuntime = async (options: {
|
||||
profileName: string;
|
||||
hooks?: TurnDaemonHooks;
|
||||
takeCommittedReadModelChangeReceipt: (() => CommittedReadModelChangeReceipt | null) | null;
|
||||
}): Promise<{ redisConnector: RedisConnector | null; hooks?: TurnDaemonHooks }> => {
|
||||
applyClockProjection?: (redis: RedisConnector['client'], workerId: string) => Promise<boolean>;
|
||||
onClockProjectionApplied?: () => void;
|
||||
}): Promise<{
|
||||
redisConnector: RedisConnector | null;
|
||||
hooks?: TurnDaemonHooks;
|
||||
stopClockProjectionWorker: () => void;
|
||||
}> => {
|
||||
const redisConfig = resolveRedisConfig(options.redisUrl);
|
||||
if (!redisConfig) {
|
||||
return { redisConnector: null, hooks: options.hooks };
|
||||
return { redisConnector: null, hooks: options.hooks, stopClockProjectionWorker: () => {} };
|
||||
}
|
||||
|
||||
const redisConnector = createRedisConnector(redisConfig);
|
||||
await redisConnector.connect();
|
||||
const redisClient = redisConnector.client;
|
||||
const clockProjectionWorkerId = `turn-daemon:${options.profileName}:${randomUUID()}`;
|
||||
let clockProjectionInFlight = false;
|
||||
let clockProjectionStopped = false;
|
||||
const recoverClockProjection = async (): Promise<void> => {
|
||||
if (!options.applyClockProjection || clockProjectionInFlight || clockProjectionStopped) return;
|
||||
clockProjectionInFlight = true;
|
||||
try {
|
||||
if (await options.applyClockProjection(redisClient, clockProjectionWorkerId)) {
|
||||
options.onClockProjectionApplied?.();
|
||||
}
|
||||
} finally {
|
||||
clockProjectionInFlight = false;
|
||||
}
|
||||
};
|
||||
await recoverClockProjection().catch(() => undefined);
|
||||
const clockProjectionTimer = options.applyClockProjection
|
||||
? setInterval(() => void recoverClockProjection().catch(() => undefined), 1_000)
|
||||
: null;
|
||||
clockProjectionTimer?.unref();
|
||||
const realtimeChannel = buildGameEventChannel(options.profileName);
|
||||
const revisionKey = buildGameReadModelRevisionKey(options.profileName);
|
||||
const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName);
|
||||
@@ -550,6 +581,9 @@ const createRealtimeRuntime = async (options: {
|
||||
await basePublishEvents?.(result);
|
||||
},
|
||||
publishCommandEvents: async (result) => {
|
||||
if (result.type === 'messageRespond' && result.ok && result.action === 'raiseInvader') {
|
||||
await recoverClockProjection();
|
||||
}
|
||||
try {
|
||||
const changes = options.takeCommittedReadModelChangeReceipt?.()?.changes;
|
||||
if (changes && hasRealtimeReadModelChanges(changes)) {
|
||||
@@ -569,7 +603,14 @@ const createRealtimeRuntime = async (options: {
|
||||
await basePublishCommandEvents?.(result);
|
||||
},
|
||||
};
|
||||
return { redisConnector, hooks };
|
||||
return {
|
||||
redisConnector,
|
||||
hooks,
|
||||
stopClockProjectionWorker: () => {
|
||||
clockProjectionStopped = true;
|
||||
if (clockProjectionTimer) clearInterval(clockProjectionTimer);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createStartedAdminActionConsumer = async (options: {
|
||||
@@ -672,6 +713,8 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
}));
|
||||
let worldRef: InMemoryTurnWorld | null = null;
|
||||
let redisConnector: RedisConnector | null = null;
|
||||
let stopClockProjectionWorker = () => {};
|
||||
let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined;
|
||||
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
||||
const monthlyActionModules = await loadActionModuleBundle(
|
||||
@@ -876,6 +919,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
},
|
||||
};
|
||||
takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt;
|
||||
applyClockProjection = dbHooks.applyClockProjection;
|
||||
close = async () => {
|
||||
if (auctionBidder) {
|
||||
await auctionBidder.close();
|
||||
@@ -926,9 +970,17 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
profileName: options.profileName ?? options.profile,
|
||||
hooks,
|
||||
takeCommittedReadModelChangeReceipt,
|
||||
...(applyClockProjection
|
||||
? {
|
||||
applyClockProjection: (redis: RedisConnector['client'], workerId: string) =>
|
||||
applyClockProjection!(redis, workerId),
|
||||
onClockProjectionApplied: () => world.completeClockReconciliation(),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
redisConnector = realtimeRuntime.redisConnector;
|
||||
hooks = realtimeRuntime.hooks;
|
||||
stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker;
|
||||
|
||||
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
|
||||
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
|
||||
@@ -950,6 +1002,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
|
||||
const baseClose = close;
|
||||
close = async () => {
|
||||
stopClockProjectionWorker();
|
||||
await baseClose();
|
||||
await neutralAuctionRegistrar.close();
|
||||
if (redisConnector) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '@sammo-ts/logic';
|
||||
|
||||
@@ -163,6 +165,14 @@ export const createUnificationHandler = (options: {
|
||||
});
|
||||
}
|
||||
}
|
||||
const sourceRevision = world.getGameClockState().revision;
|
||||
const suspensionId = `unification-wait-${createHash('sha256')
|
||||
.update(`${serverId}:${sourceRevision}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32)}`;
|
||||
world.beginUnificationWait(suspensionId);
|
||||
} else {
|
||||
world.completeGameClock();
|
||||
}
|
||||
|
||||
queueYearbookSnapshot(world, options.profileName, context.currentYear, context.currentMonth);
|
||||
|
||||
@@ -146,6 +146,10 @@ const refreshActorKillturn = (world: InMemoryTurnWorld, actor: TurnGeneral): voi
|
||||
interface CommandHandlerContext {
|
||||
world: InMemoryTurnWorld;
|
||||
commandDb?: GamePrisma.TransactionClient;
|
||||
clockOperationAuthority?: Extract<
|
||||
NonNullable<TurnDaemonCommandExecutionContext['clockOperationAuthority']>,
|
||||
{ kind: 'DAEMON' }
|
||||
>;
|
||||
auctionFinalizer?: AuctionFinalizer;
|
||||
auctionBidder?: AuctionBidder;
|
||||
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
||||
@@ -153,6 +157,7 @@ interface CommandHandlerContext {
|
||||
reservedTurns?: InMemoryReservedTurnStore;
|
||||
generalActionModules?: ReadonlyArray<GeneralActionModule>;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
reconcileUnificationWait?: Parameters<typeof respondToActionableMessage>[0]['reconcileUnificationWait'];
|
||||
}
|
||||
|
||||
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
@@ -464,10 +469,10 @@ async function handleSelectPoolCreate(
|
||||
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)
|
||||
? ctx.world.gameTickToDate(processingGameTick)
|
||||
: command.acceptedGameTick !== undefined
|
||||
? ctx.world.gameTickToDate(command.acceptedGameTick)
|
||||
: command.acceptedGameAt !== undefined
|
||||
? new Date(command.acceptedGameAt)
|
||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||
? ctx.world.gameTickToDate(command.acceptedGameTick)
|
||||
: command.acceptedGameAt !== undefined
|
||||
? new Date(command.acceptedGameAt)
|
||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
||||
try {
|
||||
return {
|
||||
@@ -1834,6 +1839,8 @@ async function handleMessageRespond(
|
||||
messageId: command.messageId,
|
||||
response: command.response,
|
||||
loadArchivedNationMaxId: ctx.loadArchivedNationMaxId,
|
||||
clockOperationAuthority: ctx.clockOperationAuthority,
|
||||
reconcileUnificationWait: ctx.reconcileUnificationWait,
|
||||
});
|
||||
return {
|
||||
type: 'messageRespond',
|
||||
@@ -3101,6 +3108,7 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
auctionBidder?: AuctionBidder;
|
||||
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||
reconcileUnificationWait?: Parameters<typeof respondToActionableMessage>[0]['reconcileUnificationWait'];
|
||||
}): TurnDaemonCommandHandler => {
|
||||
let immediateGeneralActionExecutor: Promise<ImmediateGeneralActionExecutor> | null = null;
|
||||
const ctx: CommandHandlerContext = {
|
||||
@@ -3111,6 +3119,7 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
reservedTurns: options.reservedTurns,
|
||||
generalActionModules: options.generalActionModules,
|
||||
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
||||
reconcileUnificationWait: options.reconcileUnificationWait,
|
||||
getImmediateGeneralActionExecutor: () => {
|
||||
immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({
|
||||
world: options.world,
|
||||
@@ -3232,6 +3241,7 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
return null;
|
||||
}
|
||||
ctx.commandDb = executionContext?.db;
|
||||
ctx.clockOperationAuthority = executionContext?.clockOperationAuthority;
|
||||
try {
|
||||
if (isActorBoundGeneralCommand(command)) {
|
||||
const rejected = await validateActorBoundGeneralCommand(ctx, command);
|
||||
@@ -3242,6 +3252,7 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
return await handler(command);
|
||||
} finally {
|
||||
ctx.commandDb = undefined;
|
||||
ctx.clockOperationAuthority = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user