111 lines
4.4 KiB
TypeScript
111 lines
4.4 KiB
TypeScript
import { parseGameClockPhase } from '@sammo-ts/common';
|
|
import { GamePrisma } from '@sammo-ts/infra';
|
|
|
|
import type { DatabaseClient } from '../context.js';
|
|
|
|
const safeInteger = (value: bigint, label: string): number => {
|
|
const result = Number(value);
|
|
if (!Number.isSafeInteger(result)) throw new Error(`${label} is outside the safe integer range.`);
|
|
return result;
|
|
};
|
|
|
|
export const loadClockReadiness = async (db: DatabaseClient) => {
|
|
if (!db.clockProjectionOutbox) {
|
|
return {
|
|
reconciliationComplete: false,
|
|
gameplayEnabled: false,
|
|
phase: null,
|
|
revision: null,
|
|
deadlineGeneration: null,
|
|
incompleteOutboxCount: null,
|
|
};
|
|
}
|
|
const [world, incompleteOutboxCount] = await Promise.all([
|
|
db.worldState.findFirst({
|
|
orderBy: { id: 'asc' },
|
|
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
|
|
}),
|
|
db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } }),
|
|
]);
|
|
if (!world) {
|
|
return {
|
|
reconciliationComplete: false,
|
|
gameplayEnabled: false,
|
|
phase: null,
|
|
revision: null,
|
|
deadlineGeneration: null,
|
|
incompleteOutboxCount,
|
|
};
|
|
}
|
|
const phase = parseGameClockPhase(world.clockPhase);
|
|
return {
|
|
reconciliationComplete: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
|
|
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
|
|
phase,
|
|
revision: safeInteger(world.clockRevision, 'clock revision'),
|
|
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
|
|
incompleteOutboxCount,
|
|
};
|
|
};
|
|
|
|
export const loadClockAdminStatus = async (db: DatabaseClient) => {
|
|
const readiness = await loadClockReadiness(db);
|
|
if (!db.clockSuspension) {
|
|
return { ...readiness, latestReconciliation: null };
|
|
}
|
|
const latest = await db.clockSuspension.findFirst({
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
participants: { orderBy: { participantKey: 'asc' } },
|
|
projectionOutbox: { orderBy: { id: 'asc' } },
|
|
},
|
|
});
|
|
if (!latest) return { ...readiness, latestReconciliation: null };
|
|
return {
|
|
...readiness,
|
|
latestReconciliation: {
|
|
id: latest.id,
|
|
source: latest.source,
|
|
policy: latest.policy,
|
|
status: latest.status,
|
|
sourceRevision: safeInteger(latest.sourceRevision, 'source revision'),
|
|
targetRevision: safeInteger(latest.targetRevision, 'target revision'),
|
|
cutTick: safeInteger(latest.cutTick, 'cut tick'),
|
|
alignedTick: latest.alignedTick === null ? null : safeInteger(latest.alignedTick, 'aligned tick'),
|
|
participantChecksumBefore: latest.participantChecksumBefore,
|
|
participantChecksumAfter: latest.participantChecksumAfter,
|
|
participants: latest.participants.map((participant) => ({
|
|
key: participant.participantKey,
|
|
policy: participant.policy,
|
|
beforeChecksum: participant.beforeChecksum,
|
|
afterChecksum: participant.afterChecksum,
|
|
affectedCount: participant.affectedCount,
|
|
})),
|
|
outbox: latest.projectionOutbox.map((entry) => ({
|
|
id: entry.id.toString(),
|
|
targetRevision: safeInteger(entry.targetRevision, 'outbox target revision'),
|
|
status: entry.status,
|
|
attempts: entry.attempts,
|
|
lastError: entry.lastError,
|
|
})),
|
|
},
|
|
};
|
|
};
|
|
|
|
/** API 기동과 별개로 데몬의 초기 감사 저장 및 clock 복구가 끝나야 배포 준비 완료다. */
|
|
export const loadProfileReadiness = async (db: DatabaseClient, profileName: string) => {
|
|
const [clock, leases] = await Promise.all([
|
|
loadClockReadiness(db),
|
|
db.$queryRaw<Array<{ ready: boolean }>>(GamePrisma.sql`
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM turn_daemon_lease
|
|
WHERE profile = ${profileName}
|
|
AND clock_ready = TRUE
|
|
AND lease_until > (clock_timestamp() AT TIME ZONE 'UTC')
|
|
) AS ready
|
|
`),
|
|
]);
|
|
const daemonReady = leases[0]?.ready === true;
|
|
return { ok: clock.reconciliationComplete && daemonReady, clock, daemonReady };
|
|
};
|