시간 작동 방식 변경 #1

Merged
Hide_D merged 20 commits from test/game-clock-reconciliation-20260903 into main 2026-09-04 08:37:06 +09:00
176 changed files with 13121 additions and 1644 deletions
+3 -2
View File
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
generalId: number,
input: OpenAuctionInput,
requestId?: string
): Promise<{ auctionId: number; closeAt: string }> => {
): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionOpen',
...(requestId ? { requestId } : {}),
@@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async (
const closeAt = new Date(result.closeAt);
const gameTime = await loadCurrentGameTime(ctx.db);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) },
{ score: resolveAuctionTimerScore(gameTime, closeAt, BigInt(result.closeTick)), value: String(result.auctionId) },
]);
return {
auctionId: result.auctionId,
closeAt: result.closeAt,
closeTick: result.closeTick,
};
};
+8 -7
View File
@@ -10,18 +10,19 @@ interface RedisSortedSetClient {
}
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
if (closeTick !== null && closeTick !== undefined) {
const value = Number(closeTick);
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
return value;
}
return time.dateToTick(closeAt) ?? closeAt.getTime();
void time;
void closeAt;
if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
const value = Number(closeTick);
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
return value;
};
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
if (row.status === 'FINALIZING') {
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
return time.tick ?? time.now.getTime();
if (time.tick === null) throw new Error('Current game tick is required for auction recovery.');
return time.tick;
}
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
};
+113 -56
View File
@@ -1,4 +1,7 @@
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
createGamePostgresConnector,
createRedisConnector,
type GamePrismaClient,
@@ -10,6 +13,7 @@ import { resolveGameApiConfigFromEnv } from '../config.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
import { buildAuctionTimerKeys } from './keys.js';
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
@@ -24,13 +28,27 @@ interface RedisTimerClient {
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
zRem(key: string, values: string | string[]): Promise<number>;
zRemRangeByScore(key: string, min: number, max: number): Promise<number>;
eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
}
const POP_DUE_AUCTIONS_SCRIPT = `
if redis.call('GET', KEYS[2]) ~= ARGV[1]
or redis.call('GET', KEYS[3]) ~= ARGV[2]
or redis.call('GET', KEYS[4]) ~= 'RUNNING' then
return { '__CLOCK_FENCE__' }
end
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[3], 'LIMIT', 0, ARGV[4])
if #ids > 0 then
redis.call('ZREM', KEYS[1], unpack(ids))
end
return ids
`;
const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
interface AuctionFinalizeDeadline {
closeAt: Date;
closeTick: bigint | null;
closeTick: bigint;
}
interface AuctionFinalizeCommand {
@@ -38,19 +56,10 @@ interface AuctionFinalizeCommand {
requestId: string;
auctionId: number;
expectedCloseAt: string;
expectedCloseTick?: number;
expectedCloseTick: number;
}
interface AuctionFinalizeEventRecord {
target: string;
eventType: string;
payload: unknown;
status: string;
result: unknown;
}
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
if (closeTick === null) return undefined;
const readSafeCloseTick = (closeTick: bigint): number => {
const value = Number(closeTick);
if (!Number.isSafeInteger(value)) {
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
@@ -63,14 +72,7 @@ export const buildAuctionFinalizeRequestId = (
deadline: AuctionFinalizeDeadline,
retry = 0
): string => {
const generation =
deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`;
const base = `auction:finalize:${auctionId}:${generation}`;
return retry > 0 ? `${base}:retry:${retry}` : base;
};
const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`;
const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
return retry > 0 ? `${base}:retry:${retry}` : base;
};
@@ -83,7 +85,7 @@ const buildAuctionFinalizeCommand = (
requestId,
auctionId,
expectedCloseAt: deadline.closeAt.toISOString(),
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }),
expectedCloseTick: readSafeCloseTick(deadline.closeTick),
});
const isMatchingAuctionFinalizeEvent = (
@@ -95,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = (
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
const expectedGenerationMatches =
payloadRecord?.expectedCloseTick !== undefined
? payloadRecord.expectedCloseTick === command.expectedCloseTick
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
return (
event.target === 'ENGINE' &&
event.eventType === command.type &&
@@ -115,12 +114,29 @@ const isSuccessfulAuctionFinalizeResult = (result: unknown, auctionId: number):
return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId;
};
const popDueAuctionIds = async (
export const popDueAuctionIds = async (
redis: RedisTimerClient,
timerKey: string,
nowMs: number,
batchSize: number
batchSize: number,
clockFence?: {
activeRevisionKey: string;
deadlineGenerationKey: string;
phaseKey: string;
revision: number;
generation: number;
}
): Promise<string[]> => {
if (clockFence) {
if (!redis.eval) throw new Error('Redis EVAL is required for revision-fenced auction due-pop.');
const result = await redis.eval(POP_DUE_AUCTIONS_SCRIPT, {
keys: [timerKey, clockFence.activeRevisionKey, clockFence.deadlineGenerationKey, clockFence.phaseKey],
arguments: [String(clockFence.revision), String(clockFence.generation), String(nowMs), String(batchSize)],
});
if (!Array.isArray(result)) throw new Error('Auction due-pop returned an invalid Redis result.');
if (result[0] === '__CLOCK_FENCE__') return [];
return result.map(String);
}
const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } });
if (ids.length > 0) {
await redis.zRem(timerKey, ids);
@@ -157,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: {
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
continue;
}
if (row.closeTick === null) throw new Error(`Auction ${row.id} has no GAME_TIME close authority.`);
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt);
const bases = [...new Set([canonicalBase, legacyBase])];
const events = await options.db.inputEvent.findMany({
where: {
OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]),
OR: [{ requestId: canonicalBase }, { requestId: { startsWith: `${canonicalBase}:retry:` } }],
},
select: { requestId: true, target: true, eventType: true, payload: true, status: true },
orderBy: { sequence: 'desc' },
@@ -182,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: {
timers.push({
score:
row.status === 'FINALIZING'
? (options.gameTime.tick ?? options.gameTime.now.getTime())
? (() => {
if (options.gameTime.tick === null) throw new Error('Current game tick is required.');
return options.gameTime.tick;
})()
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
value: String(row.id),
});
@@ -205,6 +223,8 @@ export const processDueAuctionId = async (options: {
nowMs: number;
nowTick?: number | null;
historyNowMs?: number;
expectedClockRevision?: number;
expectedDeadlineGeneration?: number;
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
const auctionId = Number(id);
@@ -213,6 +233,29 @@ export const processDueAuctionId = async (options: {
}
const now = new Date(nowMs);
const outcome = await db.$transaction(async (transaction) => {
if (options.expectedClockRevision !== undefined || options.expectedDeadlineGeneration !== undefined) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
const [world] = await transaction.$queryRaw<
Array<{ clockPhase: string | null; clockRevision: bigint; deadlineGeneration: bigint }>
>(GamePrisma.sql`
SELECT
clock_phase AS "clockPhase",
clock_revision AS "clockRevision",
deadline_generation AS "deadlineGeneration"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
if (
!world ||
world.clockPhase !== 'RUNNING' ||
world.clockRevision !== BigInt(options.expectedClockRevision ?? -1) ||
world.deadlineGeneration !== BigInt(options.expectedDeadlineGeneration ?? -1)
) {
return { status: 'RESCHEDULED' as const, clockFenceFailed: true };
}
}
const current = await transaction.auction.findUnique({
where: { id: auctionId },
select: { status: true, closeAt: true, closeTick: true },
@@ -221,10 +264,10 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const };
}
if (current.status === 'OPEN') {
const isDue =
current.closeTick !== null && nowTick !== null
? current.closeTick <= BigInt(nowTick)
: current.closeTick === null && current.closeAt.getTime() <= now.getTime();
if (current.closeTick === null || nowTick === null) {
throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
}
const isDue = current.closeTick <= BigInt(nowTick);
if (!isDue) {
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
}
@@ -233,24 +276,15 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const };
}
if (current.closeTick === null) throw new Error(`Auction ${auctionId} has no GAME_TIME close authority.`);
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
const candidateRequestIds = [...new Set([requestId, legacyRequestId])];
let existing: AuctionFinalizeEventRecord | null = null;
let existingRequestId = requestId;
for (const candidateRequestId of candidateRequestIds) {
existing = await transaction.inputEvent.findUnique({
where: { requestId: candidateRequestId },
select: { target: true, eventType: true, payload: true, status: true, result: true },
});
if (existing) {
existingRequestId = candidateRequestId;
break;
}
}
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
const existing = await transaction.inputEvent.findUnique({
where: { requestId },
select: { target: true, eventType: true, payload: true, status: true, result: true },
});
const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
if (!existing) {
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
await transaction.inputEvent.create({
@@ -264,14 +298,14 @@ export const processDueAuctionId = async (options: {
return { status: 'PENDING' as const };
}
if (!isMatchingAuctionFinalizeEvent(existing, command)) {
throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`);
throw new Error(`Conflicting durable auction finalization event: ${requestId}`);
}
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
return { status: 'PENDING' as const };
}
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
throw new Error(
`Auction remained ${current.status} after successful durable event: ${existingRequestId}`
`Auction remained ${current.status} after successful durable event: ${requestId}`
);
}
}
@@ -284,6 +318,10 @@ export const processDueAuctionId = async (options: {
return 'PENDING';
}
if (outcome.status === 'RESCHEDULED') {
if ('clockFenceFailed' in outcome) {
await redis.zAdd(timerKey, [{ score: nowTick ?? nowMs, value: id }]);
return 'RESCHEDULED';
}
const gameTime = await loadCurrentGameTime(db, now);
await redis.zAdd(timerKey, [
{
@@ -315,18 +353,30 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() },
]);
let nextResyncAt = Date.now();
let nextResyncAt = performance.now();
const pendingFinalizationIds = new Set<number>();
try {
while (!control.signal.aborted) {
const operationalNowMs = Date.now();
const operationalElapsedMs = performance.now();
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
const gameNowMs = gameTime.now.getTime();
const dueScore = gameTime.tick ?? gameNowMs;
if (operationalNowMs >= nextResyncAt) {
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
continue;
}
const clockFence = gameTime.phase
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
: null;
if (gameTime.phase && !clockFence) {
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
continue;
}
if (operationalElapsedMs >= nextResyncAt) {
await seedAuctionTimers(postgres.prisma, redis.client, keys);
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
}
if (pendingFinalizationIds.size > 0) {
const reconciliation = await reconcilePendingAuctionTimers({
@@ -345,7 +395,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
if (historyTrimBefore > 0) {
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
}
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100);
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100, clockFence ?? undefined);
if (dueIds.length > 0) {
for (const id of dueIds) {
try {
@@ -358,6 +408,12 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
nowMs: gameNowMs,
nowTick: gameTime.tick,
historyNowMs: operationalNowMs,
...(clockFence
? {
expectedClockRevision: clockFence.revision,
expectedDeadlineGeneration: clockFence.generation,
}
: {}),
});
if (outcome === 'PENDING') {
pendingFinalizationIds.add(Number(id));
@@ -395,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
await closeResources();
}
};
import { performance } from 'node:perf_hooks';
+9 -1
View File
@@ -65,7 +65,15 @@ export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick';
type WorldClockFields =
| 'clockBaseTime'
| 'clockTick'
| 'clockMode'
| 'clockWallAnchor'
| 'lastTurnTick'
| 'clockPhase'
| 'clockRevision'
| 'deadlineGeneration';
type GeneralClockFields = 'turnTick' | 'recentWarTick';
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma
+29 -20
View File
@@ -1,10 +1,15 @@
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { acquireGameSchemaAdvisoryXactLock, type DatabaseClient, type GamePrisma } from '@sammo-ts/infra';
import {
acquireGameSchemaAdvisoryXactLock,
readInputEventClockCoordinate,
type DatabaseClient,
type GamePrisma,
} from '@sammo-ts/infra';
import type { TurnDaemonTransport } from './transport.js';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
@@ -82,9 +87,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
async sendCommand(command: TurnDaemonCommand): Promise<string> {
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
if (durableCommand.type === 'npcPossessGeneral') {
delete durableCommand.acceptedGameAt;
}
// Rolling-upgrade compatibility: older API versions supplied game
// coordinates. They are deliberately not persisted as command facts;
// the daemon assigns the authoritative processing coordinate while
// claiming the input event under the clock fence.
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameAt;
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameTick;
if (command.type === 'npcPossessGeneral') {
const existing = await this.db.inputEvent.findUnique({
where: { requestId },
@@ -103,15 +111,14 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
try {
if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
const rejectionReason = await this.db.$transaction(async (transaction) => {
const coordinate = await readInputEventClockCoordinate(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
const token = await transaction.npcSelectionToken.findFirst({
where: {
ownerUserId: command.userId,
nonce: command.tokenNonce,
validUntil: { gte: acceptedGameAt },
validUntilTick: { gte: coordinate.gameTick },
},
select: { pickResult: true },
});
@@ -126,18 +133,20 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
) {
return '선택한 장수가 목록에 없습니다.';
}
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = {
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
acceptedGameAt: acceptedGameAt.toISOString(),
};
await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt);
await this.createInputEvent(transaction, durableCommand, requestId);
return null;
});
if (rejectionReason) {
throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason);
}
} else {
await this.createInputEvent(this.db, durableCommand, requestId);
if (this.db.$transaction) {
await this.db.$transaction(async (transaction) => {
await this.createInputEvent(transaction, durableCommand, requestId);
});
} else {
await this.createInputEvent(this.db, durableCommand, requestId);
}
}
} catch (error) {
if (error instanceof RejectedNpcPossessionCommandError) {
@@ -165,8 +174,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
private async createInputEvent(
db: DatabaseClient,
command: TurnDaemonCommand,
requestId: string,
createdAt?: Date
requestId: string
): Promise<void> {
await db.inputEvent.create({
data: {
@@ -175,7 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
eventType: command.type,
payload: asJson(command),
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
...(createdAt ? { createdAt } : {}),
// PostgreSQL owns created_at WALL_TIME. ENGINE assigns the
// authoritative game coordinate when the daemon claims it.
},
});
}
@@ -192,8 +201,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
}
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
while (Date.now() < deadline) {
const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
while (performance.now() < deadline) {
const event = await this.db.inputEvent.findUnique({
where: { requestId },
select: { status: true, result: true, error: true },
@@ -204,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
if (event?.status === 'FAILED') {
throw new FailedTurnDaemonCommandError(requestId, event.error);
}
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
await delay(Math.min(50, Math.max(1, deadline - performance.now())));
}
return null;
}
+74 -50
View File
@@ -1,6 +1,11 @@
import { createHash } from 'node:crypto';
import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
type DatabaseClient as InfraDatabaseClient,
} from '@sammo-ts/infra';
import type { DatabaseClient } from './context.js';
@@ -29,8 +34,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
$executeRawUnsafe(query: string): Promise<number>;
};
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const canonicalJson = (value: unknown): string =>
JSON.stringify(value, (_key, entry: unknown) => {
if (typeof entry === 'bigint') {
@@ -85,6 +88,9 @@ const insertPendingIfAbsent = async (
actor_user_id,
status,
attempts,
accepted_game_tick,
accepted_clock_revision,
accepted_deadline_generation,
created_at
)
VALUES (
@@ -95,6 +101,9 @@ const insertPendingIfAbsent = async (
${options.actorUserId},
'PENDING'::"InputEventStatus",
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT (request_id) DO NOTHING
@@ -153,20 +162,22 @@ const claimInputEvent = async (
requestId: string,
payloadIdentity: ApiInputPayloadIdentity
): Promise<void> => {
await db.inputEvent.update({
where: { requestId },
data: {
payload: asJson(payloadIdentity),
status: 'PROCESSING',
result: GamePrisma.DbNull,
error: null,
attempts: { increment: 1 },
lockedBy: null,
leaseUntil: null,
processingAt: new Date(),
completedAt: null,
},
});
await db.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
status = 'PROCESSING'::"InputEventStatus",
result = NULL,
error = NULL,
attempts = attempts + 1,
locked_by = NULL,
lease_until = NULL,
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
processing_game_tick = NULL,
processing_clock_revision = NULL,
processing_deadline_generation = NULL,
completed_at = NULL
WHERE request_id = ${requestId}
`);
};
const markUnexpectedFailure = async (
@@ -177,6 +188,7 @@ const markUnexpectedFailure = async (
actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity;
error: unknown;
acquireClockFence: boolean;
}
): Promise<void> => {
if (!db.$transaction) return;
@@ -184,6 +196,9 @@ const markUnexpectedFailure = async (
try {
await db.$transaction(async (transaction) => {
if (options.acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, options);
const row = await lockInputEvent(transaction, options.requestId);
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
@@ -192,20 +207,19 @@ const markUnexpectedFailure = async (
// late failure recorder must never replace its durable success.
return;
}
await transaction.inputEvent.update({
where: { requestId: options.requestId },
data: {
payload: asJson(options.payloadIdentity),
status: 'FAILED',
result: GamePrisma.DbNull,
error: message,
attempts: { increment: 1 },
lockedBy: null,
leaseUntil: null,
processingAt: new Date(),
completedAt: new Date(),
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
status = 'FAILED'::"InputEventStatus",
result = NULL,
error = ${message},
attempts = attempts + 1,
locked_by = NULL,
lease_until = NULL,
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE request_id = ${options.requestId}
`);
});
} catch {
// Preserve the transaction failure that the caller actually observed. If
@@ -220,10 +234,12 @@ export const executeInputEvent = async <T>(options: {
eventType: string;
payload: unknown;
actorUserId?: string | null;
acquireClockFence?: boolean;
execute(db: DatabaseClient): Promise<T>;
}): Promise<T> => {
const { db, requestId, eventType, payload, execute } = options;
const actorUserId = options.actorUserId ?? null;
const acquireClockFence = options.acquireClockFence !== false;
const payloadIdentity = createApiInputPayloadIdentity(payload);
if (!db.$transaction) {
return execute(db);
@@ -233,6 +249,9 @@ export const executeInputEvent = async <T>(options: {
let outcome: InputEventOutcome<T>;
try {
outcome = await db.$transaction(async (transaction) => {
if (acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
const row = await lockInputEvent(transaction, requestId);
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
@@ -258,36 +277,41 @@ export const executeInputEvent = async <T>(options: {
try {
const value = await execute(transaction);
const durableResult = canonicalJsonValue(value);
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'SUCCEEDED',
result: asJson(durableResult),
error: null,
completedAt: new Date(),
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'SUCCEEDED'::"InputEventStatus",
result = CAST(${JSON.stringify(durableResult)} AS jsonb),
error = NULL,
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE request_id = ${requestId}
`);
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
return { kind: 'executed', value };
} catch (error) {
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'FAILED',
result: GamePrisma.DbNull,
error: message,
completedAt: new Date(),
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'FAILED'::"InputEventStatus",
result = NULL,
error = ${message},
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE request_id = ${requestId}
`);
return { kind: 'failed', error };
}
});
} catch (error) {
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error });
await markUnexpectedFailure(db, {
requestId,
eventType,
actorUserId,
payloadIdentity,
error,
acquireClockFence,
});
}
throw error;
}
+141 -87
View File
@@ -1,9 +1,13 @@
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
import {
enqueuePrivateMessageWebPush,
GamePrisma,
persistMessageEnvelope,
type MessageGameContext,
} from '@sammo-ts/infra';
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
export interface MessageView {
id: number;
@@ -22,7 +26,9 @@ interface MessageRow {
src: number;
dest: number;
time: Date;
valid_until: Date;
created_at_wall: Date;
action_status: string | null;
expires_game_tick: bigint | null;
message: unknown;
}
@@ -48,70 +54,66 @@ const formatMessageTime = (value: Date): string => {
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
};
const messageValidityPredicate = (gameTime: CurrentGameTime) => {
if (gameTime.tick === null) {
// A legacy or partially migrated profile has no authoritative logical
// tick. Rows that already carry a tick still need the wall-time
// fallback used by the clock migration.
return GamePrisma.sql`valid_until > ${gameTime.now}`;
}
return GamePrisma.sql`(
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)`;
};
const toMessageView = (row: MessageRow): MessageView => {
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
const payload = parsePayload(row.message);
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
const actionUnavailable =
actionStatus !== null &&
(actionStatus !== 'PENDING' ||
(row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick));
return {
id: row.id,
msgType: row.type,
src: payload.src,
dest: row.type === 'public' ? null : payload.dest,
text: payload.text,
option: payload.option ?? null,
time: formatMessageTime(new Date(row.time)),
option:
actionUnavailable && payload.option && typeof payload.option === 'object'
? { ...payload.option, used: true, invalid: true }
: (payload.option ?? null),
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
};
};
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
const gameTime = await loadCurrentGameTime(db);
const toTickOrNull = (date: Date): bigint | null => {
// Ref represents its unlimited 9999-12-31 message lifetime with the
// largest safe game tick instead of falling back to a wall-clock-only row.
if (date.getUTCFullYear() >= 9000) {
return BigInt(MAX_SAFE_GAME_TICK);
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
let gameContext: MessageGameContext | null = null;
if (typeof action === 'string' && action !== '') {
const gameTime = await loadCurrentGameTime(db);
if (
gameTime.tick === null ||
gameTime.revision === null ||
gameTime.revision === undefined ||
gameTime.deadlineGeneration === null ||
gameTime.deadlineGeneration === undefined
) {
throw new Error(`Actionable message ${action} requires an initialized game clock.`);
}
try {
const tick = gameTime.dateToTick(date);
return tick === null ? null : BigInt(tick);
} catch {
return null;
let expiresGameTick: bigint | null = null;
if (draft.validUntil.getUTCFullYear() < 9000) {
const expires = gameTime.dateToTick(draft.validUntil);
if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
expiresGameTick = BigInt(expires);
}
};
const rows = await db.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${toTickOrNull(draft.time)},
${draft.validUntil},
${toTickOrNull(draft.validUntil)},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) {
throw new Error('Failed to insert message row.');
gameContext = {
occurredGameTick: BigInt(gameTime.tick),
clockRevision: BigInt(gameTime.revision),
deadlineGeneration: BigInt(gameTime.deadlineGeneration),
expiresGameTick,
};
}
const id = await persistMessageEnvelope(db, draft, gameContext);
await enqueuePrivateMessageWebPush(db, draft, id);
return id;
};
const loadMessageViews = async (db: DatabaseClient, rows: MessageRow[]): Promise<MessageView[]> => {
if (!rows.some((row) => typeof row.action_status === 'string')) return rows.map((row) => toMessageView(row, null));
const gameTime = await loadCurrentGameTime(db);
const currentGameTick = gameTime.tick === null ? null : BigInt(gameTime.tick);
return rows.map((row) => toMessageView(row, currentGameTick));
};
export const fetchMessagesFromMailbox = async (params: {
db: DatabaseClient;
mailbox: number;
@@ -120,19 +122,20 @@ export const fetchMessagesFromMailbox = async (params: {
fromSeq: number;
}): Promise<MessageView[]> => {
const fromSeq = Math.max(params.fromSeq, 0);
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE mailbox = ${params.mailbox}
AND type = ${params.msgType}
AND ${messageValidityPredicate(gameTime)}
AND id >= ${fromSeq}
ORDER BY id DESC
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
m.created_at_wall, m.message,
ma.status AS action_status, ma.expires_game_tick
FROM message m
LEFT JOIN message_action ma ON ma.message_id = m.id
WHERE m.mailbox = ${params.mailbox}
AND m.type = ${params.msgType}
AND m.id >= ${fromSeq}
ORDER BY m.id DESC
LIMIT ${params.limit}
`;
return rows.map(toMessageView);
return loadMessageViews(params.db, rows);
};
export const fetchOldMessagesFromMailbox = async (params: {
@@ -142,28 +145,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
toSeq: number;
limit: number;
}): Promise<MessageView[]> => {
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE mailbox = ${params.mailbox}
AND type = ${params.msgType}
AND ${messageValidityPredicate(gameTime)}
AND id < ${params.toSeq}
ORDER BY id DESC
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
m.created_at_wall, m.message,
ma.status AS action_status, ma.expires_game_tick
FROM message m
LEFT JOIN message_action ma ON ma.message_id = m.id
WHERE m.mailbox = ${params.mailbox}
AND m.type = ${params.msgType}
AND m.id < ${params.toSeq}
ORDER BY m.id DESC
LIMIT ${params.limit}
`;
return rows.map(toMessageView);
return loadMessageViews(params.db, rows);
};
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db);
const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id}
AND ${messageValidityPredicate(gameTime)}
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
m.created_at_wall, m.message,
ma.status AS action_status, ma.expires_game_tick
FROM message m
LEFT JOIN message_action ma ON ma.message_id = m.id
WHERE m.id = ${id}
LIMIT 1
`;
const row = rows[0];
@@ -172,20 +177,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
id: row.id,
mailbox: row.mailbox,
msgType: row.type,
time: new Date(row.time),
time: new Date(row.created_at_wall ?? row.time),
payload: parsePayload(row.message),
};
};
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db);
if (gameTime.tick === null) throw new Error('Actionable message response requires an initialized game clock.');
const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id}
AND ${messageValidityPredicate(gameTime)}
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
m.created_at_wall, m.message,
ma.status AS action_status, ma.expires_game_tick
FROM message m
JOIN message_action ma ON ma.message_id = m.id
JOIN world_state world ON TRUE
WHERE m.id = ${id}
AND ma.status = 'PENDING'
AND (ma.expires_game_tick IS NULL OR ma.expires_game_tick > ${BigInt(gameTime.tick)})
AND world.clock_phase IN ('RUNNING', 'MANUAL')
AND ma.clock_revision = world.clock_revision
AND ma.deadline_generation = world.deadline_generation
LIMIT 1
FOR UPDATE
FOR UPDATE OF m, ma, world
`;
const row = rows[0];
if (!row) return null;
@@ -193,7 +207,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
id: row.id,
mailbox: row.mailbox,
msgType: row.type,
time: new Date(row.time),
time: new Date(row.created_at_wall ?? row.time),
payload: parsePayload(row.message),
};
};
@@ -202,16 +216,16 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return;
const gameTime = await loadCurrentGameTime(db);
if (gameTime.tick === null) throw new Error('Actionable message invalidation requires an initialized game clock.');
await db.messageAction.updateMany({
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick: BigInt(gameTime.tick) },
});
await db.message.updateMany({
where: { id: { in: uniqueIds } },
data: {
validUntil: gameTime.now,
// A partially migrated profile can still carry a legacy logical
// sentinel even while no authoritative clock exists. Replace it
// with an already-expired logical tick when expiring by wall time;
// NULL would fall back to the wall timestamp after clock recovery
// and could make the handled message visible again.
validUntilTick: gameTime.tick === null ? 0n : BigInt(gameTime.tick),
validUntilTick: BigInt(gameTime.tick),
},
});
};
@@ -233,8 +247,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
END
) || jsonb_build_object('invalid', true),
true
)
),
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id IN (${GamePrisma.join(uniqueIds)})
`
);
};
export const tombstoneMessagesWithinDeleteWindow = async (
db: DatabaseClient,
authorityMessageId: number,
ids: number[]
): Promise<number[]> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return [];
const rows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
WITH wall AS (
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
), authority AS (
SELECT m.id
FROM message m, wall
WHERE m.id = ${authorityMessageId}
AND m.tombstoned_at_wall IS NULL
AND m.delete_until_wall >= wall.now_wall
FOR UPDATE
)
UPDATE message m
SET message = jsonb_set(
jsonb_set(m.message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
'{option}',
(
CASE
WHEN jsonb_typeof(m.message->'option') = 'object' THEN m.message->'option'
ELSE '{}'::jsonb
END
) || jsonb_build_object('invalid', true),
true
),
tombstoned_at_wall = wall.now_wall
FROM wall
WHERE m.id IN (${GamePrisma.join(uniqueIds)})
AND EXISTS (SELECT 1 FROM authority)
RETURNING m.id
`);
return rows.map(({ id }) => id).sort((left, right) => left - right);
};
+4 -10
View File
@@ -60,10 +60,7 @@ export interface AuctionDetail {
export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null },
time: { now: Date; tick: number | null }
): boolean =>
auction.closeTick !== null && time.tick !== null
? auction.closeTick < BigInt(time.tick)
: auction.closeAt.getTime() < time.now.getTime();
): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
interface AuctionBidRow {
id: number;
@@ -433,7 +430,6 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
throwIfCommandRejected(result);
@@ -448,7 +444,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]);
return { ok: true };
@@ -511,7 +507,6 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
throwIfCommandRejected(result);
@@ -526,7 +521,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]);
return { ok: true };
@@ -650,7 +645,6 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
});
throwIfCommandRejected(result);
@@ -665,7 +659,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]);
return { ok: true };
+40 -5
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server';
import { GamePrisma } from '@sammo-ts/infra';
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import { z } from 'zod';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
return world;
};
interface BettingClockFenceRow {
currentYear: number;
currentMonth: number;
clockPhase: string;
clockRevision: bigint;
deadlineGeneration: bigint;
}
const lockBettingClockFence = async (db: Parameters<typeof getMyGeneral>[0]['db']): Promise<BettingClockFenceRow> => {
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
const rows = await db.$queryRaw<BettingClockFenceRow[]>(GamePrisma.sql`
SELECT current_year AS "currentYear",
current_month AS "currentMonth",
clock_phase AS "clockPhase",
clock_revision AS "clockRevision",
deadline_generation AS "deadlineGeneration"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
const world = rows[0];
if (!world) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' });
}
if (!['RUNNING', 'MANUAL', 'SUSPENDED'].includes(world.clockPhase)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Nation betting is disabled while the game clock phase is ${world.clockPhase}.`,
});
}
return world;
};
export const bettingRouter = router({
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
.query(async ({ ctx, input }) => {
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
async ({ ctx, input }) => {
requireUserId(ctx.auth);
await getMyGeneral(ctx);
const [world, rows] = await Promise.all([
@@ -66,7 +100,8 @@ export const bettingRouter = router({
year: world.currentYear,
month: world.currentMonth,
};
}),
}
),
getDetail: authedProcedure
.input(z.object({ bettingId: z.number().int().positive() }))
@@ -141,7 +176,7 @@ export const bettingRouter = router({
if (betting.finished) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
}
const world = await loadWorldDate(ctx.db);
const world = await lockBettingClockFence(ctx.db);
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
if (betting.closeYearMonth <= yearMonth) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
+5 -8
View File
@@ -15,7 +15,7 @@ import {
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
import { insertMessage } from '../../messages/store.js';
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { readDatabaseWallTime } from '../../services/wallClock.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -306,8 +306,6 @@ export const diplomacyRouter = router({
nationColor: destNation.color,
},
};
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
const created = await ctx.db.diplomacyLetter.create({
data: {
srcNationId: srcNation.id,
@@ -316,7 +314,6 @@ export const diplomacyRouter = router({
state: 'PROPOSED',
textBrief: purifyDiplomacyHtml(input.brief),
textDetail: purifyDiplomacyHtml(input.detail),
date: letterDate,
srcSignerId: me.id,
aux: aux as GamePrisma.InputJsonValue,
},
@@ -332,7 +329,7 @@ export const diplomacyRouter = router({
src: srcTarget,
dest: destTarget,
text,
time: letterDate,
time: created.date,
});
return { id: created.id };
@@ -371,7 +368,7 @@ export const diplomacyRouter = router({
);
const messageSrc = buildActorTarget(me, destNation);
const messageDest = buildNationTarget(srcNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux);
let messageText: string;
if (input.agree) {
@@ -458,7 +455,7 @@ export const diplomacyRouter = router({
);
const messageSrc = buildActorTarget(me, srcNation);
const messageDest = buildNationTarget(destNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux);
aux.reason = {
who: me.id,
@@ -519,7 +516,7 @@ export const diplomacyRouter = router({
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
const messageSrc = buildActorTarget(me, actorNation);
const messageDest = buildNationTarget(otherNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
const messageTime = await readDatabaseWallTime(ctx.db);
let resultState: 'ACTIVATED' | 'CANCELLED';
let messageText: string;
+43 -31
View File
@@ -4,6 +4,11 @@ import { z } from 'zod';
import type { GameApiContext, WorldStateRow } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
} from '@sammo-ts/infra';
import {
isWarTraitKey,
JOIN_PERSONALITY_TRAIT_KEYS,
@@ -393,15 +398,12 @@ export const joinRouter = router({
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const gameTime = await loadCurrentGameTime(ctx.db);
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReserve',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionReservationCommandResult(result);
}),
@@ -431,7 +433,6 @@ export const joinRouter = router({
});
}
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}),
@@ -440,8 +441,6 @@ export const joinRouter = router({
uniqueName: input.uniqueName,
personality: input.personality,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
...(selectedIcon
? {
ownerPicture: selectedIcon.picture,
@@ -471,15 +470,12 @@ export const joinRouter = router({
input.clientRequestId,
'reselect'
);
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReselect',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionCommandResult(result, 'selectPoolReselect');
}),
@@ -583,30 +579,46 @@ export const joinRouter = router({
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
});
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
try {
const gameTime = await loadCurrentGameTime(ctx.db);
if (gameTime.tick === null) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Game clock is not initialized.',
return await ctx.db.$transaction!(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
SELECT clock_phase AS "clockPhase"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
if (!clockRows[0]) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
});
}
const worldState = await transaction.worldState.findFirst();
const gameTime = await loadCurrentGameTime(transaction);
if (!worldState || gameTime.tick === null) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Game clock is not initialized.',
});
}
return reserveNpcPossessionCandidates({
db: transaction,
worldState,
userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
createdGameTick: gameTime.tick,
});
}
return await reserveNpcPossessionCandidates({
db: ctx.db,
worldState,
userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
acceptedGameTick: gameTime.tick,
});
} catch (error) {
if (error instanceof NpcPossessionError) {
+94 -34
View File
@@ -1,11 +1,19 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { asRecord, ChangeJournal } from '@sammo-ts/common';
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GameApiContext } from '../../context.js';
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import {
accessLimitAuthedInputProcedure,
accessWallAuthedInputProcedure,
authedProcedure,
engineAuthedProcedure,
router,
wallAuthedProcedure,
} from '../../trpc.js';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
@@ -20,13 +28,13 @@ import {
fetchOldMessagesFromMailbox,
fetchMessageById,
insertMessage,
tombstoneMessages,
tombstoneMessagesWithinDeleteWindow,
type MessageView,
} from '../../messages/store.js';
import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js';
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { executeInputEvent } from '../../inputEventBoundary.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -231,7 +239,7 @@ export const messagesRouter = router({
})),
};
}),
readLatest: authedProcedure
readLatest: wallAuthedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
@@ -264,7 +272,7 @@ export const messagesRouter = router({
`;
return { ok: true };
}),
delete: authedProcedure
delete: wallAuthedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
@@ -289,17 +297,16 @@ export const messagesRouter = router({
if (message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
}
const { now } = await loadCurrentGameTime(ctx.db);
if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMessageId = message.payload.option?.receiverMessageID;
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
const ids = [
message.id,
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
];
await tombstoneMessages(ctx.db, ids);
const deletedIds = await tombstoneMessagesWithinDeleteWindow(ctx.db, message.id, ids);
if (!deletedIds.includes(message.id)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMailbox =
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
? message.payload.dest.generalId
@@ -309,9 +316,9 @@ export const messagesRouter = router({
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
: null;
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
return { ok: true, deletedIds: ids };
return { ok: true, deletedIds };
}),
respond: authedProcedure
respond: engineAuthedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
@@ -342,29 +349,80 @@ export const messagesRouter = router({
}
return { result: commandResult.ok, reason: commandResult.reason };
}
const result = await respondToDiplomaticMessage({
const ownsChangeJournal = !ctx.changeJournal;
const changeJournal = ctx.changeJournal ?? new ChangeJournal();
let journalPersisted = false;
const response = await executeInputEvent({
db: ctx.db,
actor: general,
messageId: input.messageId,
response: input.response,
requestId: `messages.respond.diplomatic:${input.messageId}`,
eventType: 'messages.respond.diplomatic',
payload: input,
actorUserId: ctx.auth?.user.id,
execute: async (transaction) => {
const transactionContext = { ...ctx, db: transaction, changeJournal };
const transactionGeneral = ownsChangeJournal
? await getOwnedGeneral(transactionContext, input.generalId)
: general;
const result = await respondToDiplomaticMessage({
db: transaction,
actor: transactionGeneral,
messageId: input.messageId,
response: input.response,
});
markMessageMailboxes(transactionContext, result.affectedMailboxes);
for (const generalId of result.affectedGeneralRecordIds) {
changeJournal.mark('records.general', generalId);
}
for (const nationId of result.affectedNationIds) {
changeJournal.mark('nation.content', nationId);
}
for (const cityId of result.affectedCityIds) {
changeJournal.mark('city.content', cityId);
}
if (result.affectedCityIds.length > 0) {
changeJournal.mark('map.world');
}
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
changeJournal.mark('dashboard.global');
}
if (ownsChangeJournal) {
journalPersisted = Boolean(
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
);
}
return {
result: result.result,
reason: result.reason,
affectedNationIds: result.affectedNationIds,
affectedCityIds: result.affectedCityIds,
};
},
});
markMessageMailboxes(ctx, result.affectedMailboxes);
for (const generalId of result.affectedGeneralRecordIds) {
ctx.changeJournal?.mark('records.general', generalId);
if (journalPersisted) {
ctx.readModelOutbox?.wake();
}
for (const nationId of result.affectedNationIds) {
ctx.changeJournal?.mark('nation.content', nationId);
if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const synchronized = await ctx.turnDaemon.requestCommand({
type: 'syncDiplomaticResponse',
userId: ctx.auth.user.id,
generalId: general.id,
messageId: input.messageId,
nationIds: response.affectedNationIds,
cityIds: response.affectedCityIds,
});
if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) {
const synchronizationReason =
synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined;
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
});
}
}
for (const cityId of result.affectedCityIds) {
ctx.changeJournal?.mark('city.content', cityId);
}
if (result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('map.world');
}
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('dashboard.global');
}
return { result: result.result, reason: result.reason };
return { result: response.result, reason: response.reason };
}),
getOld: authedProcedure
.input(
@@ -420,7 +478,7 @@ export const messagesRouter = router({
...messageBuckets,
};
}),
send: accessAuthedInputProcedure(
send: accessWallAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
@@ -436,7 +494,9 @@ export const messagesRouter = router({
}
const src = await buildTargetFromGeneral(ctx.db, general);
const { now } = await loadCurrentGameTime(ctx.db);
// Compatibility-only projection. persistMessageEnvelope records and
// displays the authoritative PostgreSQL wall instant.
const now = new Date();
const validUntil = new Date('9999-12-31T00:00:00Z');
let msgType: MessageType;
+83 -13
View File
@@ -5,12 +5,14 @@ import { asRecord } from '@sammo-ts/common';
import type { TournamentType } from '@sammo-ts/logic';
import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore } from '../../tournament/store.js';
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js';
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
@@ -44,6 +46,63 @@ const adminProcedure = authedProcedure.use(({ ctx, next }) => {
return next();
});
const withTournamentClockMutation = async <T>(
ctx: {
db: Parameters<typeof loadCurrentGameTime>[0];
redis: Parameters<typeof ensureActiveRedisClockFence>[0];
profile: { name: string };
},
store: TournamentStore,
operation: () => Promise<T>
): Promise<T> => {
const gameTime = await loadCurrentGameTime(ctx.db);
const fence = await ensureActiveRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
if (!fence) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Clock reconciliation is incomplete; tournament mutation is disabled.',
});
}
const clockContext: TournamentClockContext = {
phase: fence.phase,
revision: fence.revision,
deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick,
};
return store.withClockContext(clockContext, () => store.withMutationLock(operation));
};
const withTournamentBetClockMutation = async <T>(
ctx: {
db: Parameters<typeof loadCurrentGameTime>[0];
redis: Parameters<typeof ensureBettingRedisClockFence>[0];
profile: { name: string };
},
store: TournamentStore,
operation: () => Promise<T>
): Promise<T> => {
const gameTime = await loadCurrentGameTime(ctx.db);
const fence = await ensureBettingRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
if (!fence) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Clock reconciliation is incomplete; tournament betting is disabled.',
});
}
return store.withClockContext(
{
phase: fence.phase,
revision: fence.revision,
deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick,
},
() => store.withMutationLock(operation)
);
};
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
const zTournamentState = z.object({
stage: z.number().int().min(0),
phase: z.number().int().min(0),
@@ -143,7 +202,10 @@ export const tournamentRouter = router({
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.getState();
}),
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
getAdminStatus: adminProcedure.query(async ({ ctx }) => ({
ok: true,
clock: await loadClockAdminStatus(ctx.db),
})),
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
@@ -271,7 +333,7 @@ export const tournamentRouter = router({
}),
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
await store.setState({
...input,
type: input.type as TournamentType,
@@ -281,7 +343,7 @@ export const tournamentRouter = router({
}),
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
const current = await store.getState();
if (!current) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
@@ -297,21 +359,21 @@ export const tournamentRouter = router({
}),
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
await store.setParticipants(input);
return { ok: true, count: input.length };
});
}),
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
await store.setMatches(input);
return { ok: true, count: input.length };
});
}),
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
await store.setBettingEntries(input);
return { ok: true, count: input.length };
});
@@ -347,7 +409,7 @@ export const tournamentRouter = router({
};
});
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
await store.setParticipants(participants);
return { ok: true, count: participants.length };
});
@@ -394,7 +456,7 @@ export const tournamentRouter = router({
join: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
const state = await store.getState();
if (!state || state.stage !== 1 || state.participantsLockedAt) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
@@ -459,7 +521,7 @@ export const tournamentRouter = router({
}),
cancel: adminProcedure.mutation(async ({ ctx }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
const state = await store.getState();
if (!state) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
@@ -513,7 +575,10 @@ export const tournamentRouter = router({
return { ok: true };
});
}),
placeBet: authedProcedure
// This route delegates its game mutations to durable ENGINE input events.
// Wrapping it in the API input-event transaction would hold the clock
// advisory lock while waiting for the daemon to claim the child event.
placeBet: engineAuthedProcedure
.input(
z.object({
targetId: z.number().int().positive(),
@@ -523,7 +588,7 @@ export const tournamentRouter = router({
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentBetClockMutation(ctx, store, async () => {
const state = await store.getState();
if (!state || state.stage !== 6) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
@@ -558,6 +623,7 @@ export const tournamentRouter = router({
const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
});
@@ -573,6 +639,7 @@ export const tournamentRouter = router({
const rankResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
reason: 'tournamentBet',
adjustments: [
{
@@ -584,6 +651,7 @@ export const tournamentRouter = router({
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
});
@@ -600,11 +668,13 @@ export const tournamentRouter = router({
await Promise.all([
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}),
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback',
adjustments: [
{
+15 -21
View File
@@ -102,18 +102,16 @@ export const hasPollEnded = (
time: CurrentGameTime
): boolean =>
Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null
? poll.end_tick < BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
Boolean(
poll.end_at &&
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
);
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null;
try {
const tick = time.dateToTick(date);
return tick === null ? null : BigInt(tick);
} catch {
return null;
}
const tick = time.dateToTick(date);
if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
return BigInt(tick);
};
type VoteListRow = {
@@ -358,7 +356,6 @@ export const voteRouter = router({
voteId: input.voteId,
generalId: general.id,
selection: sortedSelection,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
throwIfCommandRejected(rewardResult);
@@ -399,8 +396,6 @@ export const voteRouter = router({
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
: null;
const nationName = nation?.name ?? '재야';
const createdAt = new Date();
await ctx.db.$queryRaw(GamePrisma.sql`
INSERT INTO vote_comment (
vote_id,
@@ -418,7 +413,7 @@ export const voteRouter = router({
${general.name},
${nationName},
${input.text},
${createdAt}
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
`);
@@ -451,7 +446,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
const operationalAt = new Date();
let multipleOptions = input.multipleOptions;
if (multipleOptions < 0) {
@@ -464,7 +458,8 @@ export const voteRouter = router({
if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt}
SET closed_at = ${gameTime.now},
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE closed_at IS NULL
`);
}
@@ -497,8 +492,8 @@ export const voteRouter = router({
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt},
${toGameTickOrNull(gameTime, endAt)},
${operationalAt},
${operationalAt}
CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
`);
@@ -573,7 +568,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
const updatedAt = new Date();
if (
input.title === undefined &&
@@ -596,7 +590,7 @@ export const voteRouter = router({
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = ${updatedAt}
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId}
`);
@@ -609,10 +603,10 @@ export const voteRouter = router({
.input(z.object({ voteId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const gameTime = await loadCurrentGameTime(ctx.db);
const updatedAt = new Date();
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt}
SET closed_at = ${gameTime.now},
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId}
RETURNING id
`);
+12 -6
View File
@@ -50,6 +50,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
import { scopeHttpIdempotencyKey } from './requestId.js';
import { loadClockReadiness } from './services/clockReadiness.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) {
@@ -420,12 +421,17 @@ export const createGameApiServer = async () => {
request.raw.on('aborted', close);
});
app.get('/healthz', async () => ({
ok: true,
profile: config.profileName,
postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(),
}));
app.get('/healthz', async (_request, reply) => {
const clock = await loadClockReadiness(postgres.prisma);
if (!clock.reconciliationComplete) reply.code(503);
return {
ok: clock.reconciliationComplete,
profile: config.profileName,
postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(),
clock,
};
});
try {
await realtimeHub.start();
@@ -0,0 +1,92 @@
import { parseGameClockPhase } from '@sammo-ts/common';
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,
})),
},
};
};
+39 -3
View File
@@ -1,4 +1,10 @@
import { GameClock, type GameClockMode } from '@sammo-ts/common';
import {
GameClock,
inferClockPhase,
parseGameClockPhase,
type GameClockMode,
type GameClockPhase,
} from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
@@ -7,6 +13,9 @@ export interface CurrentGameTime {
wallNow: Date;
tick: number | null;
mode: GameClockMode | null;
phase?: GameClockPhase | null;
revision?: number | null;
deadlineGeneration?: number | null;
running: boolean;
startsAt: Date | null;
dateToTick(date: Date): number | null;
@@ -19,6 +28,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow,
tick: null,
mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true,
startsAt: null,
dateToTick: () => null,
@@ -32,6 +44,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockMode: true,
clockWallAnchor: true,
tickSeconds: true,
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
},
});
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
@@ -40,32 +55,53 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow,
tick: null,
mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true,
startsAt: null,
dateToTick: () => null,
};
}
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
// During the dual-read migration an older profile (or a rolling-deploy
// fixture) can lack clock_phase. Preserve the existing future-anchor
// PREOPEN contract until every profile has the authoritative column.
const phase = state.clockPhase
? parseGameClockPhase(state.clockPhase)
: mode === 'realtime' && wallNow.getTime() < state.clockWallAnchor.getTime()
? 'PREOPEN'
: inferClockPhase(mode);
const storedTick = Number(state.clockTick);
if (!Number.isSafeInteger(storedTick)) {
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`);
}
const revision = Number(state.clockRevision ?? 1n);
const deadlineGeneration = Number(state.deadlineGeneration ?? 1n);
if (!Number.isSafeInteger(revision) || !Number.isSafeInteger(deadlineGeneration)) {
throw new Error('world_state clock revision or deadline generation is outside the safe integer range.');
}
const clock = new GameClock({
baseTime: state.clockBaseTime,
tick: storedTick,
mode,
wallAnchor: state.clockWallAnchor,
turnSeconds: state.tickSeconds,
phase,
revision,
});
const tick = clock.nowTick(wallNow);
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime();
const running = phase === 'RUNNING' && mode === 'realtime';
return {
now: clock.tickToDate(tick),
wallNow,
tick,
mode,
phase,
revision,
deadlineGeneration,
running,
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null,
startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null,
dateToTick: (date) => clock.dateToTick(date),
};
};
@@ -0,0 +1,84 @@
import type { CurrentGameTime } from './gameClock.js';
import type { GameClockPhase } from '@sammo-ts/common';
interface ClockFenceRedis {
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
}
const BOOTSTRAP_CLOCK_FENCE_SCRIPT = `
local revision = redis.call('GET', KEYS[1])
local generation = redis.call('GET', KEYS[2])
local phase = redis.call('GET', KEYS[3])
if not revision and not generation and not phase then
redis.call('SET', KEYS[1], ARGV[1])
redis.call('SET', KEYS[2], ARGV[2])
redis.call('SET', KEYS[3], ARGV[3])
return 1
end
if revision == ARGV[1] and generation == ARGV[2] and phase == ARGV[3] then
return 2
end
return 0
`;
export interface ActiveRedisClockFence {
activeRevisionKey: string;
deadlineGenerationKey: string;
phaseKey: string;
revision: number;
generation: number;
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
}
type MutableProjectionPhase = ActiveRedisClockFence['phase'];
const ensureRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime,
allowedPhases: readonly GameClockPhase[]
): Promise<ActiveRedisClockFence | null> => {
if (
!gameTime.phase ||
!allowedPhases.includes(gameTime.phase) ||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
!Number.isSafeInteger(gameTime.revision) ||
!Number.isSafeInteger(gameTime.deadlineGeneration)
) {
return null;
}
const phase: MutableProjectionPhase = gameTime.phase;
const fence: ActiveRedisClockFence = {
activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
phaseKey: `sammo:${profileName}:clock:phase`,
revision: gameTime.revision!,
generation: gameTime.deadlineGeneration!,
phase,
};
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey],
arguments: [String(fence.revision), String(fence.generation), phase],
});
return Number(result) === 1 || Number(result) === 2 ? fence : null;
};
export const ensureActiveRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> => {
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
};
/**
* User betting is allowed against a frozen tournament deadline while the game
* clock is suspended. Stage progression and settlement continue to use the
* RUNNING-only helper above.
*/
export const ensureBettingRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> =>
ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING', 'MANUAL', 'SUSPENDED']);
+18 -13
View File
@@ -1,32 +1,37 @@
import { performance } from 'node:perf_hooks';
import { gatewayProfileCapabilities } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
interface TurnDaemonLeaseSource {
turnDaemonLease: {
findUnique(input: {
where: { profile: string };
select: { leaseUntil: true };
}): Promise<{ leaseUntil: Date } | null>;
};
$queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
}
export const loadTurnEngineRunning = async (
source: ProfileStatusSource | undefined,
db: TurnDaemonLeaseSource,
profileName: string,
now = new Date()
now?: Date
): Promise<boolean | null> => {
if (!source) return null;
try {
const status = await source.get(profileName);
if (status === null) return null;
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
const lease = await db.turnDaemonLease.findUnique({
where: { profile: profileName },
select: { leaseUntil: true },
});
return lease !== null && lease.leaseUntil.getTime() > now.getTime();
const wallNow = now
? GamePrisma.sql`${now}`
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
SELECT EXISTS (
SELECT 1
FROM turn_daemon_lease
WHERE profile = ${profileName}
AND lease_until > ${wallNow}
) AS running
`);
return rows[0]?.running ?? false;
} catch {
return null;
}
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
private readonly db: TurnDaemonLeaseSource,
private readonly profileName: string,
private readonly cacheMs = 2_000,
private readonly now = () => Date.now()
private readonly now = () => performance.now()
) {}
get(): Promise<boolean | null> {
+13
View File
@@ -0,0 +1,13 @@
import { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js';
/** Reads the authoritative PostgreSQL UTC wall instant for business rules. */
export const readDatabaseWallTime = async (db: Pick<DatabaseClient, '$queryRaw'>): Promise<Date> => {
const rows = await db.$queryRaw<Array<{ wallNow: Date }>>(GamePrisma.sql`
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow"
`);
const wallNow = rows[0]?.wallNow;
if (!wallNow) throw new Error('Failed to read PostgreSQL wall time.');
return new Date(wallNow);
};
@@ -1,4 +1,5 @@
import { createHmac, randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
`);
if (rows.length === 0) return [];
const ids = rows.map((row) => row.id);
await tx.webPushOutbox.updateMany({
where: { id: { in: ids } },
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
});
await tx.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
SET "locked_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"lock_owner" = ${this.owner},
"attempts" = "attempts" + 1
WHERE "id" IN (${GamePrisma.join(ids)})
`);
return tx.webPushOutbox.findMany({
where: { id: { in: ids }, lockOwner: this.owner },
orderBy: { id: 'asc' },
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
});
for (const event of claimed) {
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
});
const expired = await this.db.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = NULL
WHERE "id" = ${event.id}
AND "lock_owner" = ${this.owner}
AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
- ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond'
`);
if (expired > 0) {
continue;
}
try {
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
});
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = NULL
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
} catch (error) {
const attempts = event.attempts;
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: {
availableAt: new Date(Date.now() + delaySeconds * 1_000),
lockedAt: null,
lockOwner: null,
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
},
});
const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
"locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = ${errorText}
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
this.onError(error);
}
}
if (Date.now() >= this.nextPruneAt) {
this.nextPruneAt = Date.now() + 60_000;
if (performance.now() >= this.nextPruneAt) {
this.nextPruneAt = performance.now() + 60_000;
await this.db.$executeRaw(GamePrisma.sql`
WITH expired AS (
SELECT "id"
+6
View File
@@ -8,6 +8,9 @@ export interface TournamentKeys {
sourceRevisionKey: string;
sourceRevisionChannel: string;
realtimeEventChannel: string;
activeClockRevisionKey: string;
deadlineGenerationKey: string;
clockPhaseKey: string;
}
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
@@ -18,4 +21,7 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
activeClockRevisionKey: `sammo:${profileName}:clock:active-revision`,
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
clockPhaseKey: `sammo:${profileName}:clock:phase`,
});
+64 -5
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common';
import { performance } from 'node:perf_hooks';
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
import { z } from 'zod';
import type { TournamentKeys } from './keys.js';
@@ -37,8 +38,12 @@ const zTournamentState = z
openMonth: z.number().int(),
termSeconds: z.number(),
nextAt: z.string(),
nextTick: z.number().int().safe().optional(),
clockRevision: z.number().int().positive().safe().optional(),
deadlineGeneration: z.number().int().positive().safe().optional(),
bettingId: z.number().int().optional(),
bettingCloseAt: z.string().optional(),
bettingCloseTick: z.number().int().safe().optional(),
winnerId: z.number().int().optional(),
bettingSettled: z.boolean().optional(),
rewardSettled: z.boolean().optional(),
@@ -131,12 +136,62 @@ const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T
return parsed.data;
};
export interface TournamentClockContext {
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
revision: number;
deadlineGeneration: number;
dateToTick(date: Date): number | null;
}
const parseDeadlineTick = (value: string, context: TournamentClockContext, field: string): number => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error(`Tournament ${field} is not a valid instant.`);
}
const tick = context.dateToTick(date);
if (tick === null || !Number.isSafeInteger(tick)) {
throw new Error(`Tournament ${field} cannot be represented in the active game clock.`);
}
return tick;
};
export const stampTournamentClock = (state: TournamentState, context: TournamentClockContext): TournamentState => ({
...state,
nextTick: parseDeadlineTick(state.nextAt, context, 'nextAt'),
clockRevision: context.revision,
deadlineGeneration: context.deadlineGeneration,
...(state.bettingCloseAt
? { bettingCloseTick: parseDeadlineTick(state.bettingCloseAt, context, 'bettingCloseAt') }
: { bettingCloseTick: undefined }),
});
const toClockFence = (keys: TournamentKeys, context: TournamentClockContext): TournamentClockFence => ({
activeRevisionKey: keys.activeClockRevisionKey,
deadlineGenerationKey: keys.deadlineGenerationKey,
phaseKey: keys.clockPhaseKey,
revision: context.revision,
deadlineGeneration: context.deadlineGeneration,
phase: context.phase,
});
export class TournamentStore {
private clockContext: TournamentClockContext | null = null;
constructor(
private readonly redis: RedisClientLike,
private readonly keys: TournamentKeys
) {}
async withClockContext<T>(context: TournamentClockContext, operation: () => Promise<T>): Promise<T> {
const previous = this.clockContext;
this.clockContext = context;
try {
return await operation();
} finally {
this.clockContext = previous;
}
}
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
if (!this.redis.del) {
return operation();
@@ -144,8 +199,8 @@ export class TournamentStore {
const lockKey = `${this.keys.stateKey}:mutation-lock`;
const token = randomUUID();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) {
try {
@@ -174,11 +229,15 @@ export class TournamentStore {
}
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
return writeTournamentProjection(this.redis, this.keys, [{ key, value }]);
const fence = this.clockContext ? toClockFence(this.keys, this.clockContext) : undefined;
return writeTournamentProjection(this.redis, this.keys, [{ key, value }], fence);
}
async setState(state: TournamentState): Promise<string> {
return this.writeWithSourceRevision(this.keys.stateKey, state);
return this.writeWithSourceRevision(
this.keys.stateKey,
this.clockContext ? stampTournamentClock(state, this.clockContext) : state
);
}
async getParticipants(): Promise<TournamentParticipantEntry[]> {
+4
View File
@@ -9,8 +9,12 @@ export interface TournamentState {
openMonth: number;
termSeconds: number;
nextAt: string;
nextTick?: number;
clockRevision?: number;
deadlineGeneration?: number;
bettingId?: number;
bettingCloseAt?: string;
bettingCloseTick?: number;
winnerId?: number;
bettingSettled?: boolean;
rewardSettled?: boolean;
+106 -34
View File
@@ -13,9 +13,10 @@ import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
import type { TurnDaemonTransport } from '../daemon/transport.js';
import { buildTournamentKeys } from './keys.js';
import { TournamentStore } from './store.js';
import { TournamentStore, stampTournamentClock, type TournamentClockContext } from './store.js';
import type { TournamentMatchEntry, TournamentState } from './types.js';
import {
applyGroupMatch,
@@ -595,41 +596,63 @@ export const processTournamentTick = async (options: {
prisma: GamePrismaClient;
daemonTransport: TurnDaemonTransport;
now?: () => number;
clockContext?: TournamentClockContext;
}): Promise<TournamentState | null> => {
const { store, prisma, daemonTransport } = options;
const now = options.now ?? Date.now;
let processedState: TournamentState | null = null;
await store.withMutationLock(async () => {
const state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) {
return;
}
const nextAt = new Date(state.nextAt).getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
return;
}
const processWithLock = async (): Promise<void> =>
store.withMutationLock(async () => {
const state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) {
return;
}
if (
options.clockContext &&
(state.clockRevision !== options.clockContext.revision ||
state.deadlineGeneration !== options.clockContext.deadlineGeneration)
) {
throw new Error('Tournament state does not match the active clock revision.');
}
const nextAt = new Date(state.nextAt).getTime();
const notDue = options.clockContext
? !Number.isSafeInteger(state.nextTick) ||
state.nextTick! > options.clockContext.dateToTick(new Date(now()))!
: Number.isFinite(nextAt) && nextAt > now();
if (state.auto && notDue) {
if (options.clockContext && !Number.isSafeInteger(state.nextTick)) {
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
}
return;
}
if (needsSettlement(state)) {
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
return;
}
if (needsSettlement(state)) {
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
return;
}
const worldState = await prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = state;
if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
}
processedState =
(await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
})) ?? nextState;
});
const worldState = await prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = state;
if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
}
processedState =
(await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
})) ?? nextState;
});
if (options.clockContext) {
await store.withClockContext(options.clockContext, processWithLock);
} else {
await processWithLock();
}
return processedState;
};
@@ -656,16 +679,60 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
try {
while (!control.signal.aborted) {
const state = await store.getState();
let state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const gameTime = await loadCurrentGameTime(postgres.prisma);
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const clockFence = gameTime.phase
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
: null;
if (gameTime.phase && !clockFence) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const clockContext: TournamentClockContext | undefined = clockFence
? {
phase: 'RUNNING',
revision: clockFence.revision,
deadlineGeneration: clockFence.generation,
dateToTick: gameTime.dateToTick,
}
: undefined;
if (
clockContext &&
(!Number.isSafeInteger(state.nextTick) ||
state.clockRevision === undefined ||
state.deadlineGeneration === undefined)
) {
state = stampTournamentClock(state, clockContext);
await store.withClockContext(clockContext, () => store.setState(state!));
}
if (
clockContext &&
(state.clockRevision !== clockContext.revision ||
state.deadlineGeneration !== clockContext.deadlineGeneration)
) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const nextAt = new Date(state.nextAt).getTime();
const gameNow = (await loadCurrentGameTime(postgres.prisma)).now.getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > gameNow) {
await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - gameNow));
const gameNow = gameTime.now.getTime();
const notDue = clockContext
? state.nextTick! > gameTime.tick!
: Number.isFinite(nextAt) && nextAt > gameNow;
if (state.auto && notDue) {
await waitForWorkerPoll(
control.signal,
Math.min(config.tournamentPollMs, Math.max(1, nextAt - gameNow))
);
continue;
}
@@ -675,6 +742,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
prisma: postgres.prisma,
daemonTransport,
now: () => gameNow,
clockContext,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -700,7 +768,11 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
lastError: message,
lastErrorAt: failedAt,
};
await store.setState(nextState);
if (clockContext) {
await store.withClockContext(clockContext, () => store.setState(nextState));
} else {
await store.setState(nextState);
}
}
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
+4 -1
View File
@@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => {
const resolveScheduledBaseMs = (state: TournamentState): number => {
const scheduled = new Date(state.nextAt).getTime();
return Number.isFinite(scheduled) ? scheduled : Date.now();
if (!Number.isFinite(scheduled)) {
throw new Error('Tournament GAME_TIME schedule is invalid.');
}
return scheduled;
};
export const resolveNextAt = (state: TournamentState): string =>
+71 -52
View File
@@ -62,61 +62,68 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
}
const createInputEventMiddleware = (acquireClockFence: boolean) =>
t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
}
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
const payload = await getRawInput();
const changeJournal = new ChangeJournal();
let journalPersisted = false;
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
try {
const response = await executeInputEvent({
db: ctx.db,
requestId,
eventType: path,
payload,
actorUserId: ctx.auth?.user.id,
execute: async (transaction) => {
const result = await next({
ctx: {
...ctx,
db: transaction,
changeJournal,
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
},
});
if (!result.ok) {
throw result.error;
}
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
executedResult = result;
return result.data;
},
});
if (journalPersisted) {
ctx.readModelOutbox?.wake();
}
if (executedResult) {
return executedResult;
}
return {
marker: middlewareMarker,
ok: true,
data: response,
};
} catch (error) {
if (error instanceof DuplicateInputEventError) {
throw new TRPCError({
code: 'CONFLICT',
message: error.message,
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
const payload = await getRawInput();
const changeJournal = new ChangeJournal();
let journalPersisted = false;
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
try {
const response = await executeInputEvent({
db: ctx.db,
requestId,
eventType: path,
payload,
actorUserId: ctx.auth?.user.id,
acquireClockFence,
execute: async (transaction) => {
const result = await next({
ctx: {
...ctx,
db: transaction,
changeJournal,
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
},
});
if (!result.ok) {
throw result.error;
}
journalPersisted = Boolean(
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
);
executedResult = result;
return result.data;
},
});
if (journalPersisted) {
ctx.readModelOutbox?.wake();
}
if (executedResult) {
return executedResult;
}
return {
marker: middlewareMarker,
ok: true,
data: response,
};
} catch (error) {
if (error instanceof DuplicateInputEventError) {
throw new TRPCError({
code: 'CONFLICT',
message: error.message,
});
}
throw error;
}
throw error;
}
});
});
const inputEventMiddleware = createInputEventMiddleware(true);
const wallInputEventMiddleware = createInputEventMiddleware(false);
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
@@ -180,10 +187,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
export const authedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware)
.use(inputEventMiddleware);
export const wallAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware)
.use(wallInputEventMiddleware);
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
@@ -234,6 +246,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware)
.use(inputEventMiddleware);
export const accessWallAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware)
.use(wallInputEventMiddleware);
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure
.use(requireAuthMiddleware)
@@ -56,6 +56,14 @@ const lifecycleState: TurnWorldState = {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T10:00:00.000Z'),
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-31T10:00:00.000Z'),
lastTurnTick: 0,
clockPhase: 'MANUAL',
clockRevision: 1,
deadlineGeneration: 1,
meta: { killturn: 24, scenarioMeta },
};
const lifecycleGeneral: TurnGeneral = {
@@ -121,6 +129,14 @@ integration('account icon reset reconciliation PostgreSQL queue', () => {
currentYear: lifecycleState.currentYear,
currentMonth: lifecycleState.currentMonth,
tickSeconds: lifecycleState.tickSeconds,
clockBaseTime: lifecycleState.clockBaseTime,
clockTick: BigInt(lifecycleState.clockTick ?? 0),
clockMode: lifecycleState.clockMode ?? 'manual',
clockWallAnchor: lifecycleState.clockWallAnchor,
lastTurnTick: BigInt(lifecycleState.lastTurnTick ?? 0),
clockPhase: lifecycleState.clockPhase ?? 'MANUAL',
clockRevision: BigInt(lifecycleState.clockRevision ?? 1),
deadlineGeneration: BigInt(lifecycleState.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
},
+8 -11
View File
@@ -96,6 +96,7 @@ const buildContext = (options: {
ok: true as const,
auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
};
}
return {
@@ -103,6 +104,7 @@ const buildContext = (options: {
ok: true as const,
auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
};
});
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
@@ -112,14 +114,10 @@ const buildContext = (options: {
currentYear: 200,
currentMonth: 1,
tickSeconds: 3600,
...(options.clockTick === undefined
? {}
: {
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
clockTick: BigInt(options.clockTick),
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
}),
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
clockTick: BigInt(options.clockTick ?? 100),
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
config: {
const: {
auctionName: ['청룡', '백호', '주작', '현무'],
@@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => {
tick: 72_000_001,
})
).toBe(true);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(true);
});
it('rejects unauthenticated auction reads', async () => {
@@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31,
generalId: 7,
amount: 110,
acceptedGameTick: 100,
tryExtendCloseDate: false,
});
});
@@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => {
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN',
closeAt: new Date(Date.now() + 60 * 60_000),
closeTick: 200n,
},
];
}
@@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31,
generalId: 7,
amount: 500,
acceptedGameTick: 100,
tryExtendCloseDate: true,
});
});
@@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { amount: 100 },
status,
closeAt,
closeTick: 0n,
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
},
});
@@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => {
return auction;
};
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
buildAuctionFinalizeRequestId(auction.id, {
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
if (auction.closeTick === null || auction.closeTick === undefined) {
throw new Error(`auction ${auction.id} fixture requires closeTick`);
}
return buildAuctionFinalizeRequestId(auction.id, {
closeAt: auction.closeAt,
closeTick: auction.closeTick ?? null,
closeTick: auction.closeTick,
});
};
const memoryRedis = () => ({
zRangeByScore: vi.fn(async () => []),
@@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
await expect(
@@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
@@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
@@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => {
requestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: auction.id },
payload: {
type: 'auctionFinalize',
requestId,
auctionId: auction.id,
expectedCloseAt: auction.closeAt.toISOString(),
expectedCloseTick: Number(auction.closeTick),
},
status: 'FAILED',
attempts: 3,
error: 'simulated terminal failure',
@@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
await expect(
@@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
await expect(
@@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
});
it('creates a new generation after an earlier close was extended', async () => {
const auction = await createAuction('OPEN');
const priorRequestId = `auction:finalize:${auction.id}:${auction.closeAt.getTime() - 300_000}`;
const priorRequestId = `auction:finalize:${auction.id}:tick:-1`;
await connector.prisma.inputEvent.create({
data: {
requestId: priorRequestId,
@@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
@@ -299,6 +317,14 @@ liveDescribe('auction worker durable recovery', () => {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T11:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T11:00:00.000Z'),
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-07-31T11:00:00.000Z'),
lastTurnTick: 0,
clockPhase: 'RUNNING',
clockRevision: 1,
deadlineGeneration: 1,
meta: { killturn: 24, scenarioMeta },
};
const buildGeneral = (options: {
@@ -381,6 +407,14 @@ liveDescribe('auction worker durable recovery', () => {
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
clockBaseTime: state.clockBaseTime,
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'realtime',
clockWallAnchor: state.clockWallAnchor,
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
clockPhase: state.clockPhase ?? 'RUNNING',
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
@@ -415,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 200,
eventId: `auction-durable-bid:${auction.id}`,
eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
},
});
const requestId = requestIdFor(auction);
@@ -425,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
});
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
@@ -493,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { remainCloseDateExtensionCnt: 1 },
status: 'OPEN',
closeAt: logicalPastCloseAt,
closeTick: 0n,
},
});
extensionAuctionId = extensionAuction.id;
@@ -505,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 50,
eventId: `auction-extension-bid:${extensionAuction.id}`,
eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
meta: { tryExtendCloseDate: true },
},
});
@@ -516,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(extensionAuction.id),
nowMs: Date.now(),
nowTick: 0,
});
let reopened: { status: string; closeAt: Date } | null = null;
+63 -14
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra';
import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
import { popDueAuctionIds, processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
const buildRedis = () => ({
@@ -33,7 +33,7 @@ const buildDb = (options: {
$executeRaw: vi.fn(async () => options.updated),
auction: {
findUnique: vi.fn(async () =>
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? 72_000_000n } : null
),
},
inputEvent: {
@@ -55,6 +55,38 @@ const buildDb = (options: {
};
describe('auction worker clock-shift race', () => {
it('uses one Redis script for revision, generation, phase, due-read, and removal', async () => {
const redis = { ...buildRedis(), eval: vi.fn(async () => ['7', '9']) };
await expect(
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
activeRevisionKey: 'clock-revision',
deadlineGenerationKey: 'deadline-generation',
phaseKey: 'clock-phase',
revision: 4,
generation: 8,
})
).resolves.toEqual(['7', '9']);
expect(redis.eval).toHaveBeenCalledWith(expect.stringContaining('ZRANGEBYSCORE'), {
keys: ['auction-timer', 'clock-revision', 'deadline-generation', 'clock-phase'],
arguments: ['4', '8', '123456', '100'],
});
expect(redis.zRangeByScore).not.toHaveBeenCalled();
expect(redis.zRem).not.toHaveBeenCalled();
});
it('returns no due member when the atomic Redis clock fence rejects the pop', async () => {
const redis = { ...buildRedis(), eval: vi.fn(async () => ['__CLOCK_FENCE__']) };
await expect(
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
activeRevisionKey: 'clock-revision',
deadlineGenerationKey: 'deadline-generation',
phaseKey: 'clock-phase',
revision: 4,
generation: 8,
})
).resolves.toEqual([]);
});
it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => {
const now = new Date('2026-07-30T12:00:00.000Z');
const time = {
@@ -201,11 +233,12 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 36_000_000,
})
).resolves.toBe('RESCHEDULED');
expect(redis.zAdd).toHaveBeenCalledTimes(1);
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]);
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
});
@@ -235,7 +268,7 @@ describe('auction worker clock-shift race', () => {
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
@@ -247,6 +280,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs,
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
@@ -261,6 +295,7 @@ describe('auction worker clock-shift race', () => {
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
},
});
@@ -318,6 +353,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
@@ -330,7 +366,7 @@ describe('auction worker clock-shift race', () => {
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({
updated: 0,
auction: { status: 'OPEN', closeAt },
@@ -344,6 +380,7 @@ describe('auction worker clock-shift race', () => {
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
status: 'PENDING',
result: null,
@@ -359,6 +396,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
@@ -379,6 +417,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: logicalNowMs,
nowTick: 72_000_000,
historyNowMs: operationalNowMs,
});
@@ -388,12 +427,12 @@ describe('auction worker clock-shift race', () => {
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
const requestId = 'auction:finalize:7:tick:72000000';
const existingEvent = {
requestId,
target: 'ENGINE' as const,
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'PENDING' as const,
result: null,
};
@@ -411,6 +450,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
@@ -423,7 +463,7 @@ describe('auction worker clock-shift race', () => {
it('creates one bounded successor after a terminal event failure', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
const requestId = 'auction:finalize:7:tick:72000000';
const retryRequestId = `${requestId}:retry:1`;
const { db, transaction } = buildDb({
updated: 0,
@@ -433,7 +473,7 @@ describe('auction worker clock-shift race', () => {
requestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'FAILED',
result: null,
},
@@ -448,6 +488,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
@@ -461,6 +502,7 @@ describe('auction worker clock-shift race', () => {
requestId: retryRequestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
},
});
@@ -468,19 +510,23 @@ describe('auction worker clock-shift race', () => {
it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
const redis = buildRedis();
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
const closeAt = new Date('2026-07-30T11:30:00.000Z');
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
const previousRequestId = 'auction:finalize:7:tick:36000000';
const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({
updated: 0,
auction: { status: 'OPEN', closeAt },
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
existingEvents: [
{
requestId: previousRequestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 },
payload: {
type: 'auctionFinalize',
requestId: previousRequestId,
auctionId: 7,
expectedCloseTick: 36_000_000,
},
status: 'SUCCEEDED',
result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
},
@@ -495,6 +541,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
@@ -508,6 +555,7 @@ describe('auction worker clock-shift race', () => {
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
},
});
@@ -527,6 +575,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).rejects.toThrow('event insert failed');
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js';
import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js';
describe('clock reconciliation readiness', () => {
it('fails closed when the reconciliation schema is not available', async () => {
const db = {} as DatabaseClient;
await expect(loadClockReadiness(db)).resolves.toEqual({
reconciliationComplete: false,
gameplayEnabled: false,
phase: null,
revision: null,
deadlineGeneration: null,
incompleteOutboxCount: null,
});
});
it('blocks readiness for RECONCILING or incomplete outbox state', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockPhase: 'RECONCILING',
clockRevision: 9n,
deadlineGeneration: 4n,
})),
},
clockProjectionOutbox: { count: vi.fn(async () => 1) },
} as unknown as DatabaseClient;
await expect(loadClockReadiness(db)).resolves.toMatchObject({
reconciliationComplete: false,
gameplayEnabled: false,
phase: 'RECONCILING',
revision: 9,
deadlineGeneration: 4,
incompleteOutboxCount: 1,
});
});
it('exposes participant checksums and incomplete outbox detail to admins', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockPhase: 'RUNNING',
clockRevision: 3n,
deadlineGeneration: 2n,
})),
},
clockProjectionOutbox: { count: vi.fn(async () => 0) },
clockSuspension: {
findFirst: vi.fn(async () => ({
id: 'maintenance-1',
source: 'MAINTENANCE',
policy: 'EXACT',
status: 'APPLIED',
sourceRevision: 2n,
targetRevision: 3n,
cutTick: 100n,
alignedTick: 130n,
participantChecksumBefore: 'before-all',
participantChecksumAfter: 'after-all',
participants: [
{
participantKey: 'general-turn',
policy: 'SHIFT',
beforeChecksum: 'before',
afterChecksum: 'after',
affectedCount: 2,
},
],
projectionOutbox: [{ id: 8n, targetRevision: 3n, status: 'APPLIED', attempts: 1, lastError: null }],
})),
},
} as unknown as DatabaseClient;
await expect(loadClockAdminStatus(db)).resolves.toMatchObject({
reconciliationComplete: true,
latestReconciliation: {
id: 'maintenance-1',
participantChecksumBefore: 'before-all',
participantChecksumAfter: 'after-all',
participants: [{ key: 'general-turn', policy: 'SHIFT', affectedCount: 2 }],
outbox: [{ id: '8', status: 'APPLIED' }],
},
});
});
});
+1 -10
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
@@ -88,7 +87,7 @@ const storedLetter = {
};
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
const create = vi.fn(async () => ({ id: 9 }));
const create = vi.fn(async () => ({ id: 9, date: new Date('2026-07-31T00:00:00.000Z') }));
let messageId = 100;
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
const db = {
@@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => {
textBrief: '<p><strong>공개</strong></p>',
textDetail:
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
date: new Date('0185-01-01T00:00:00.000Z'),
}),
});
expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual(
expect.arrayContaining([9002, 'diplomacy', 9001, 9002])
);
expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy']));
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK));
expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text')))
.toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.');
});
it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
+12 -2
View File
@@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js';
import { loadCurrentGameTime } from '../src/services/gameClock.js';
const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient =>
const buildDatabase = (
mode: 'realtime' | 'manual' = 'realtime',
phase: 'PREOPEN' | 'RUNNING' | 'MANUAL' = mode === 'manual' ? 'MANUAL' : 'PREOPEN'
): DatabaseClient =>
({
worldState: {
findFirst: vi.fn(async () => ({
@@ -12,6 +15,9 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
clockMode: mode,
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
tickSeconds: 600,
clockPhase: phase,
clockRevision: 1n,
deadlineGeneration: 1n,
})),
},
}) as unknown as DatabaseClient;
@@ -26,11 +32,15 @@ describe('current game time projection', () => {
wallNow: new Date('2026-08-21T10:30:00.000Z'),
tick: -108_000_000,
mode: 'realtime',
phase: 'PREOPEN',
running: false,
startsAt: new Date('2026-08-21T11:00:00.000Z'),
});
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
const opened = await loadCurrentGameTime(
buildDatabase('realtime', 'RUNNING'),
new Date('2026-08-21T11:00:05.000Z')
);
expect(opened).toMatchObject({
now: new Date('2026-08-21T11:00:05.000Z'),
tick: 300_000,
@@ -326,18 +326,14 @@ describe('general access tracking', () => {
: [{ id: 41 }];
}),
$executeRaw: vi.fn(async (query: unknown) => {
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) {
const sql = (query as { sql?: string }).sql ?? '';
if (sql.includes('INSERT INTO input_event')) {
events.push('input-event-create');
}
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
return 1;
}),
$executeRawUnsafe: vi.fn(async () => 0),
inputEvent: {
update: vi.fn(async (args: { data: { status: string } }) => {
if (args.data.status === 'FAILED') events.push('input-event-failed');
return {};
}),
},
};
const db = {
general: {
+11 -7
View File
@@ -91,7 +91,7 @@ describe('IdempotentTurnDaemonTransport', () => {
}
});
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => {
it('reuses a rolling-upgrade vote event after legacy acceptance coordinates are removed', async () => {
const persistedPayload = {
type: 'voteReward' as const,
requestId: 'vote-reward',
@@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => {
selection: [0],
acceptedGameTick: 100,
};
const currentCommand = {
type: 'voteReward' as const,
requestId: 'vote-reward',
userId: 'user-7',
voteId: 1,
generalId: 7,
selection: [0],
};
const create = async () => {
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
};
@@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => {
);
await expect(
transport.sendCommand({
...persistedPayload,
acceptedGameTick: 101,
})
transport.sendCommand(currentCommand)
).resolves.toBe('vote-reward');
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
await expect(
transport.sendCommand({
...persistedPayload,
...currentCommand,
...changedIdentity,
acceptedGameTick: 101,
})
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
}
@@ -64,6 +64,14 @@ const state: TurnWorldState = {
currentMonth: 4,
tickSeconds: 600,
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
clockBaseTime: new Date('2026-08-19T00:00:00.000Z'),
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: new Date('2026-08-19T00:00:00.000Z'),
lastTurnTick: 0,
clockPhase: 'MANUAL',
clockRevision: 1,
deadlineGeneration: 1,
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
};
@@ -176,6 +184,14 @@ integration('inherit owner lookup private messages', () => {
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
clockBaseTime: state.clockBaseTime,
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor,
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
clockPhase: state.clockPhase ?? 'MANUAL',
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
@@ -18,6 +18,7 @@ import {
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const journalGeneralIds = [9_980_081, 9_980_082] as const;
const clockScenarioCode = 'input-event-boundary';
const journalBoundaryRouter = router({
mutate: procedure
@@ -56,6 +57,21 @@ integration('API input event boundary', () => {
await db.readModelRevision.deleteMany({
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
});
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
const base = new Date('2099-09-03T00:00:00.000Z');
await db.worldState.create({
data: {
scenarioCode: clockScenarioCode,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
clockBaseTime: base,
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: base,
clockPhase: 'RUNNING',
},
});
});
afterAll(async () => {
@@ -68,6 +84,7 @@ integration('API input event boundary', () => {
await db.readModelRevision.deleteMany({
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
});
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
await close?.();
});
@@ -518,6 +535,10 @@ integration('API input event boundary', () => {
expect(event.actorUserId).toBe('user-7');
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
expect(event.acceptedGameTick).toBeNull();
expect(event.acceptedClockRevision).toBeNull();
expect(event.acceptedDeadlineGeneration).toBeNull();
expect(event.processingGameTick).toBeNull();
await expect(
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
).resolves.toBe(requestId);
+47 -3
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import { procedure, router } from '../src/trpc.js';
import { procedure, router, wallProcedure } from '../src/trpc.js';
const testRouter = router({
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
@@ -14,6 +14,14 @@ const testRouter = router({
}),
});
const wallTestRouter = router({
mutate: wallProcedure.input(z.object({})).mutation(({ ctx }) => {
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
ctx.changeJournal?.mark('front.general', 7);
return { ok: true };
}),
});
const createContext = (payload: unknown = {}) => {
const order: string[] = [];
const queryRaw = vi.fn(async (query: { sql?: string }) => {
@@ -28,6 +36,9 @@ const createContext = (payload: unknown = {}) => {
status: 'PENDING',
result: null,
attempts: 0,
acceptedGameTick: 100n,
acceptedClockRevision: 3n,
acceptedDeadlineGeneration: 2n,
},
];
}
@@ -36,8 +47,19 @@ const createContext = (payload: unknown = {}) => {
});
const transaction = {
$queryRaw: queryRaw,
$executeRaw: vi.fn(async () => {
order.push('accepted');
$executeRaw: vi.fn(async (query: { sql?: string }) => {
const sql = query.sql ?? '';
order.push(
sql.includes('pg_advisory_xact_lock')
? 'clock-fence'
: sql.includes("status = 'PROCESSING'")
? 'processing'
: sql.includes("status = 'SUCCEEDED'")
? 'succeeded'
: sql.includes("status = 'FAILED'")
? 'failed'
: 'accepted'
);
return 1;
}),
$executeRawUnsafe: vi.fn(async (statement: string) => {
@@ -88,6 +110,7 @@ describe('API input-event change journal boundary', () => {
expect(fixture.order).toEqual([
'transaction-begin',
'clock-fence',
'accepted',
'locked',
'processing',
@@ -112,6 +135,7 @@ describe('API input-event change journal boundary', () => {
expect(fixture.order).toEqual([
'transaction-begin',
'clock-fence',
'accepted',
'locked',
'processing',
@@ -126,4 +150,24 @@ describe('API input-event change journal boundary', () => {
expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).not.toHaveBeenCalled();
});
it('keeps a WALL-only mutation durable without acquiring the GAME clock fence', async () => {
const fixture = createContext();
await expect(wallTestRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
expect(fixture.order).toEqual([
'transaction-begin',
'accepted',
'locked',
'processing',
'savepoint',
'handler',
'journal',
'succeeded',
'savepoint-release',
'commit',
'wake',
]);
});
});
+1
View File
@@ -42,6 +42,7 @@ const buildContext = (
turnDaemonLease: {
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
},
$queryRaw: vi.fn(async () => [{ running: true }]),
} as unknown as DatabaseClient,
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
}) as unknown as GameApiContext;
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { createGamePostgresConnector, persistMessageEnvelope, type GamePrismaClient } from '@sammo-ts/infra';
import { tombstoneMessages } from '../src/messages/store.js';
import { tombstoneMessages, tombstoneMessagesWithinDeleteWindow } from '../src/messages/store.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
@@ -23,6 +23,51 @@ integration('message deletion tombstone persistence', () => {
afterAll(async () => close?.());
it('persists an ordinary wall-time envelope without creating a game action', async () => {
const rollback = new Error('rollback ordinary message envelope fixture');
await expect(
db.$transaction(async (transaction) => {
const target = {
generalId: 7,
generalName: '보낸이',
nationId: 0,
nationName: '재야',
color: '#000000',
icon: '',
};
const id = await persistMessageEnvelope(
transaction,
{
mailbox: 9999,
msgType: 'public',
srcId: target.generalId,
destId: 9999,
time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
payload: {
src: target,
dest: target,
text: '일반 메시지는 WALL_TIME envelope만 저장한다.',
option: {},
},
},
null
);
const message = await transaction.message.findUniqueOrThrow({
where: { id },
include: { action: true },
});
expect(message.createdAtWall).toBeInstanceOf(Date);
expect(message.deleteUntilWall.getTime() - message.createdAtWall.getTime()).toBe(5 * 60_000);
expect(message.occurredGameTick).toBeNull();
expect(message.action).toBeNull();
throw rollback;
})
).rejects.toBe(rollback);
});
it('keeps sender and receiver rows readable while replacing their bodies', async () => {
const rollback = new Error('rollback message tombstone fixture');
await expect(
@@ -70,6 +115,7 @@ integration('message deletion tombstone persistence', () => {
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.validUntil).toEqual(validUntil);
expect(row.tombstonedAtWall).not.toBeNull();
expect(row.message).toMatchObject({
text: '삭제된 메시지입니다.',
option: { invalid: true },
@@ -81,4 +127,43 @@ integration('message deletion tombstone persistence', () => {
})
).rejects.toBe(rollback);
});
it('uses the DB wall deadline even when the game clock is not advancing', async () => {
const rollback = new Error('rollback wall deletion fixture');
await expect(
db.$transaction(async (transaction) => {
const [{ now_wall: nowWall }] = await transaction.$queryRaw<Array<{ now_wall: Date }>>`
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
`;
const draft = (text: string) => ({
mailbox: 7,
type: 'private' as const,
src: 7,
dest: 8,
time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
createdAtWall: nowWall,
message: {
src: { generalId: 7 },
dest: { generalId: 8 },
text,
option: {},
},
});
const deletable = await transaction.message.create({
data: { ...draft('future wall deadline'), deleteUntilWall: new Date(nowWall.getTime() + 60_000) },
});
const expired = await transaction.message.create({
data: { ...draft('past wall deadline'), deleteUntilWall: new Date(nowWall.getTime() - 60_000) },
});
expect(
await tombstoneMessagesWithinDeleteWindow(transaction, deletable.id, [deletable.id])
).toEqual([deletable.id]);
expect(await tombstoneMessagesWithinDeleteWindow(transaction, expired.id, [expired.id])).toEqual([]);
throw rollback;
})
).rejects.toBe(rollback);
});
});
+187 -67
View File
@@ -210,7 +210,7 @@ describe('messages router missing-flow compatibility', () => {
});
expect(result.msgType).toBe('national');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
expect(queryRaw).toHaveBeenCalledOnce();
});
it('journals committed message mailbox copies instead of publishing before commit', async () => {
@@ -228,6 +228,84 @@ describe('messages router missing-flow compatibility', () => {
expect(redis.publish).not.toHaveBeenCalled();
});
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)(
'keeps ordinary public messages available while the game clock is %s',
async (clockPhase) => {
const queryRaw = vi.fn(async () => [{ id: 52 }]);
const { caller } = buildContext({
$queryRaw: queryRaw,
worldState: { findFirst: vi.fn(async () => ({ clockPhase })) },
});
await expect(
caller.messages.send({ generalId: general.id, mailbox: 9999, text: `${clockPhase} 공개 메시지` })
).resolves.toMatchObject({ msgType: 'public' });
expect(queryRaw).toHaveBeenCalledOnce();
}
);
it.each(['SUSPENDED', 'RECONCILING'] as const)(
'keeps a received recruitment letter visible with its frozen game deadline while the clock is %s',
async (clockPhase) => {
const scoutRow = {
id: 54,
mailbox: general.id,
type: 'private',
src: 8,
dest: general.id,
time: new Date('0200-01-01T00:00:00.000Z'),
created_at_wall: new Date('2026-09-03T15:00:00.000Z'),
action_status: 'PENDING',
expires_game_tick: 200n,
message: {
src: {
generalId: 8,
generalName: '등용권유자',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
dest: {
generalId: general.id,
generalName: general.name,
nationId: general.nationId,
nationName: '위',
color: '#fff',
icon: '',
},
text: '등용 권유 서신',
option: { action: 'scout' },
},
};
const { caller } = buildContext({
$queryRaw: vi.fn(async () => [scoutRow]),
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('0200-01-01T00:00:00.000Z'),
clockTick: 100n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'),
tickSeconds: 600,
clockPhase,
clockRevision: 9n,
deadlineGeneration: 4n,
})),
},
});
const result = await caller.messages.getRecent({ generalId: general.id });
expect(result.private[0]).toMatchObject({
id: scoutRow.id,
text: '등용 권유 서신',
option: { action: 'scout' },
time: '2026-09-03 15:00:00',
});
expect(result.private[0]?.option).not.toMatchObject({ invalid: true });
}
);
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
const ambassador = {
...general,
@@ -259,7 +337,7 @@ describe('messages router missing-flow compatibility', () => {
});
expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
expect(queryRaw).toHaveBeenCalledTimes(2);
});
it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => {
@@ -288,7 +366,6 @@ describe('messages router missing-flow compatibility', () => {
});
expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 },
@@ -314,7 +391,6 @@ describe('messages router missing-flow compatibility', () => {
expect(result.msgType).toBe('national');
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
});
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
@@ -555,44 +631,50 @@ describe('messages router missing-flow compatibility', () => {
});
it('invalidates a recent owned message and its receiver copy', async () => {
const queryRaw = vi.fn(async () => [
{
id: 21,
mailbox: general.id,
type: 'private',
src: general.id,
dest: 8,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
let rawCall = 0;
const queryRaw = vi.fn(async () => {
rawCall += 1;
if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
return [
{
id: 21,
mailbox: general.id,
type: 'private',
src: general.id,
dest: 8,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 8,
generalName: '받는이',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '삭제할 메시지',
option: { receiverMessageID: 22 },
},
dest: {
generalId: 8,
generalName: '받는이',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '삭제할 메시지',
option: { receiverMessageID: 22 },
},
},
]);
];
});
const changeJournal = new ChangeJournal();
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]);
expect(executeRaw).toHaveBeenCalledOnce();
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 },
@@ -601,43 +683,49 @@ describe('messages router missing-flow compatibility', () => {
});
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
const queryRaw = vi.fn(async () => [
{
id: 25,
mailbox: 9001,
type: 'diplomacy',
src: 9001,
dest: 9002,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
let rawCall = 0;
const queryRaw = vi.fn(async () => {
rawCall += 1;
if (rawCall > 1) return [{ id: 25 }];
return [
{
id: 25,
mailbox: 9001,
type: 'diplomacy',
src: 9001,
dest: 9002,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '일반 외교 메시지',
option: { receiverMessageID: 26 },
},
dest: {
generalId: 0,
generalName: '',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '일반 외교 메시지',
option: { receiverMessageID: 26 },
},
},
]);
];
});
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]);
expect(executeRaw).toHaveBeenCalledOnce();
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
});
@@ -713,8 +801,11 @@ describe('messages router missing-flow compatibility', () => {
action,
reason: 'success',
}));
const transaction = vi.fn(async () => {
throw new Error('ENGINE message response must not enter the API input-event transaction.');
});
const { caller } = buildContext(
{ $queryRaw: vi.fn(async () => [messageRow]) },
{ $queryRaw: vi.fn(async () => [messageRow]), $transaction: transaction },
{ turnDaemon: { requestCommand } }
);
@@ -734,6 +825,7 @@ describe('messages router missing-flow compatibility', () => {
response: true,
})
);
expect(transaction).not.toHaveBeenCalled();
}
);
@@ -864,8 +956,18 @@ describe('messages router missing-flow compatibility', () => {
const nationUpdate = vi.fn(async () => ({}));
const logCreateMany = vi.fn(async () => ({ count: 1 }));
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
const messageActionUpdateMany = vi.fn(async () => ({ count: 1 }));
const cityUpdate = vi.fn(async () => ({}));
const changeJournal = new ChangeJournal();
const requestCommand = vi.fn(async (command: { type: string; generalId: number; messageId: number }) => ({
type: 'syncDiplomaticResponse' as const,
ok: true,
generalId: command.generalId,
messageId: command.messageId,
nations: 2,
diplomacy: 2,
cities: 0,
}));
const { caller } = buildContext(
{
general: {
@@ -941,13 +1043,22 @@ describe('messages router missing-flow compatibility', () => {
currentYear: 200,
currentMonth: 3,
config: { environment: { mapName: 'che' } },
clockBaseTime: new Date('0200-03-01T00:00:00.000Z'),
clockTick: 1_000n,
clockMode: 'manual',
clockWallAnchor: new Date('2026-09-03T00:00:00.000Z'),
tickSeconds: 600,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
})),
},
logEntry: { createMany: logCreateMany },
message: { updateMany: messageUpdateMany },
messageAction: { updateMany: messageActionUpdateMany },
$queryRaw: queryRaw,
},
{ changeJournal }
{ changeJournal, turnDaemon: { requestCommand } }
);
return {
caller,
@@ -960,6 +1071,7 @@ describe('messages router missing-flow compatibility', () => {
messageUpdateMany,
cityUpdate,
changeJournal,
requestCommand,
};
};
@@ -973,6 +1085,14 @@ describe('messages router missing-flow compatibility', () => {
});
expect(result).toEqual({ result: true, reason: 'success' });
expect(setup.requestCommand).toHaveBeenCalledWith({
type: 'syncDiplomaticResponse',
userId: auth.user.id,
generalId: setup.actor.id,
messageId: 31,
nationIds: [1, 2],
cityIds: [],
});
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
expect(setup.nationUpdate).toHaveBeenCalledWith(
expect.objectContaining({
@@ -987,7 +1107,7 @@ describe('messages router missing-flow compatibility', () => {
);
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
where: { id: { in: [31] } },
data: { validUntil: expect.any(Date), validUntilTick: 0n },
data: { validUntil: expect.any(Date), validUntilTick: 1_000n },
});
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
});
@@ -13,13 +13,16 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const bettingId = 990_071;
const concurrentBettingId = 990_072;
const phaseBettingId = 990_073;
const generalId = 9_971;
const otherGeneralId = 9_972;
const phaseGeneralId = 9_973;
const nationId = 990_071;
const otherNationId = 990_072;
const userId = 'nation-betting-router-user';
const otherUserId = 'nation-betting-router-other-user';
const noGeneralUserId = 'nation-betting-router-no-general-user';
const phaseUserId = 'nation-betting-router-phase-user';
const auth: GameSessionTokenPayload = {
version: 1,
@@ -58,6 +61,17 @@ const noGeneralAuth: GameSessionTokenPayload = {
},
};
const phaseAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'nation-betting-router-phase-session',
user: {
...auth.user,
id: phaseUserId,
username: 'phase-bettor',
displayName: 'Phase Bettor',
},
};
integration('nation betting router', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
@@ -90,12 +104,14 @@ integration('nation betting router', () => {
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.inputEvent.deleteMany({
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
});
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.nation.createMany({
@@ -138,6 +154,17 @@ integration('nation betting router', () => {
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
{
id: phaseGeneralId,
userId: phaseUserId,
name: '정지중베팅장수',
nationId,
cityId: 1,
npcState: 0,
officerLevel: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
],
});
const world = await db.worldState.create({
@@ -169,6 +196,17 @@ integration('nation betting router', () => {
],
},
});
await db.nationBetting.create({
data: {
id: phaseBettingId,
name: '정지 중 베팅',
selectCount: 1,
requiresInheritancePoint: true,
openYearMonth: 2_400,
closeYearMonth: 2_424,
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
},
});
await db.nationBetting.create({
data: {
id: concurrentBettingId,
@@ -184,17 +222,20 @@ integration('nation betting router', () => {
data: [
{ userId, key: 'previous', value: 1_000 },
{ userId: otherUserId, key: 'previous', value: 500 },
{ userId: phaseUserId, key: 'previous', value: 500 },
],
});
});
afterAll(async () => {
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.inputEvent.deleteMany({
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
});
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.worldState.delete({ where: { id: worldStateId } });
await closeDb?.();
@@ -290,6 +331,51 @@ integration('nation betting router', () => {
).toMatchObject({ value: 250 });
});
it('accepts nation betting during suspension but rejects it during reconciliation', async () => {
const before = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
const frozenTick = before.clockTick;
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'SUSPENDED' } });
await expect(
appRouter.createCaller(buildContext('nation-betting-suspended', phaseAuth)).betting.bet({
bettingId: phaseBettingId,
bettingType: [0],
amount: 100,
})
).resolves.toEqual({ result: true });
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: 400 });
await expect(
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
).resolves.toMatchObject({ amount: 100 });
await expect(db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).resolves.toMatchObject({
clockPhase: 'SUSPENDED',
clockTick: frozenTick,
});
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RECONCILING' } });
await expect(
appRouter.createCaller(buildContext('nation-betting-reconciling', phaseAuth)).betting.bet({
bettingId: phaseBettingId,
bettingType: [0],
amount: 50,
})
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: 400 });
await expect(
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
).resolves.toMatchObject({ amount: 100 });
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RUNNING' } });
});
it('requires authentication and an owned player general for every betting operation', async () => {
await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
@@ -420,7 +420,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
});
}, 45_000);
it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => {
it('revalidates a queued token at the authoritative daemon processing tick', async () => {
const reservation = await appRouter
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
.join.listPossessCandidates({});
@@ -440,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
).rejects.toMatchObject({ code: 'TIMEOUT' });
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
const acceptedGameAt = new Date(
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time'
);
expect(acceptedGameAt.toString()).not.toBe('Invalid Date');
expect(event.acceptedGameTick).toBeNull();
expect(event.processingGameTick).toBeNull();
expect(event.payload).not.toHaveProperty('acceptedGameAt');
const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!;
await db.npcSelectionToken.update({
where: { ownerUserId: delayedUserId },
data: { validUntil: acceptedGameAt },
data: { validUntilTick: queuedAtTick },
});
await db.worldState.updateMany({
data: { clockTick: { increment: 1 } },
@@ -470,12 +470,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await startRuntime('npc-possession-delayed-retry-daemon');
await expect(
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input)
).resolves.toEqual({ ok: true, generalId: candidate.id });
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '유효한 장수 목록이 없습니다.' });
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
processingGameTick: expect.anything(),
});
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(1);
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(0);
}, 45_000);
it('serializes durable enqueue before a token refresh can replace its nonce', async () => {
+13 -21
View File
@@ -27,15 +27,16 @@ const payload = (
const createFixture = (rows: readonly object[]) => {
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]);
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
const executeRaw = vi.fn().mockResolvedValue(1);
const incr = vi.fn().mockResolvedValue(41);
const publish = vi.fn().mockResolvedValue(1);
const db = {
$queryRaw: queryRaw,
readModelOutbox: { updateMany },
$executeRaw: executeRaw,
readModelOutbox: {},
} as unknown as ReadModelOutboxDatabase;
const redis = { incr, publish } as unknown as RedisConnector['client'];
return { db, redis, queryRaw, updateMany, incr, publish };
return { db, redis, queryRaw, executeRaw, incr, publish };
};
describe('ReadModelOutboxWorker', () => {
@@ -47,7 +48,7 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
@@ -64,7 +65,7 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
@@ -74,9 +75,7 @@ describe('ReadModelOutboxWorker', () => {
revision: 41,
changes: { frontStatusActorIds: [7] },
});
expect(fixture.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
);
expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"');
});
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
@@ -89,7 +88,7 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
@@ -105,7 +104,7 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
@@ -150,22 +149,15 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
);
expect(fixture.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null },
data: expect.objectContaining({
lockedAt: null,
lockOwner: null,
lastError: expect.stringContaining('redis unavailable'),
}),
})
);
const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(releaseQuery.sql).toContain('"available_at"');
expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable'));
});
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
+1 -3
View File
@@ -710,7 +710,7 @@ describe('appRouter', () => {
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('queues selection-pool reservation with the authenticated actor and server logical time', async () => {
it('queues selection-pool reservation without pre-assigning an API game coordinate', async () => {
const transport = new InMemoryTurnDaemonTransport();
const requestId = 'select-pool-reserve-http';
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
@@ -741,8 +741,6 @@ describe('appRouter', () => {
requestId: commandRequestId,
userId: 'user-1',
seedOwnerIdentity: 'user-1',
acceptedGameAt,
acceptedGameTick: 0,
});
});
@@ -227,19 +227,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
?.some((candidate) => reservedNames.has(candidate.uniqueName))
).toBe(false);
await expect(
db.inputEvent.findUniqueOrThrow({
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
})
).resolves.toMatchObject({
const reserveEvent = await db.inputEvent.findUniqueOrThrow({
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
});
expect(reserveEvent).toMatchObject({
eventType: 'selectPoolReserve',
status: 'SUCCEEDED',
actorUserId: userId,
payload: {
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
processingGameTick: expect.anything(),
});
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameTick');
const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
const attempts = await Promise.allSettled([
@@ -357,6 +355,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
const cooledAt = '2026-07-29T00:00:00.000Z';
const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await expect(
turnDaemon.requestCommand({
type: 'patchGeneral',
@@ -366,6 +365,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: {
next_change: cooledAt,
nextChangeAt: cooledAt,
next_change_tick: cooledTick,
},
},
})
@@ -380,16 +380,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.createCaller(buildContext('select-pool-reselect'))
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
).resolves.toEqual({ ok: true, generalId: initial.id });
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } })
).resolves.toMatchObject({
const reselectionEvent = await db.inputEvent.findUniqueOrThrow({
where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' },
});
expect(reselectionEvent).toMatchObject({
eventType: 'selectPoolReselect',
actorUserId: userId,
payload: {
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
processingGameTick: expect.anything(),
});
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick');
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
expect(updated).toMatchObject({
@@ -455,6 +455,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
});
const secondCooledAt = '2026-07-28T00:00:00.000Z';
const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await turnDaemon.requestCommand({
type: 'patchGeneral',
requestId: 'select-pool-full-cooldown-patch',
@@ -463,6 +464,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: {
next_change: secondCooledAt,
nextChangeAt: secondCooledAt,
next_change_tick: secondCooledTick,
},
},
});
@@ -571,21 +573,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.join.selectPoolGeneral(stableInput);
expect(retried).toEqual(first);
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
await expect(
db.inputEvent.findUniqueOrThrow({
where: {
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
},
})
).resolves.toMatchObject({
const stableEvent = await db.inputEvent.findUniqueOrThrow({
where: {
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
},
});
expect(stableEvent).toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: otherUserId,
payload: {
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
processingGameTick: expect.anything(),
});
expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick');
}, 30_000);
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
+117 -8
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
@@ -30,11 +30,31 @@ class MemoryRedis {
}
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
const [valueKey, revisionKey] = options.keys;
const [value] = options.arguments;
if (options.keys.length === 3 && options.keys[0]?.endsWith(':clock:active-revision')) {
const current = options.keys.map((key) => this.values.get(key));
if (current.every((value) => value === undefined)) {
options.keys.forEach((key, index) => this.values.set(key, options.arguments[index]!));
return '1';
}
return current.every((value, index) => value === options.arguments[index]) ? '2' : '0';
}
const fenced = options.keys.at(-1)?.endsWith(':clock:phase') === true;
const writeCount = options.keys.length - (fenced ? 4 : 1);
if (fenced) {
const clockKeys = options.keys.slice(-3);
const expected = options.arguments.slice(-3);
if (!clockKeys.every((key, index) => this.values.get(key) === expected[index])) {
return '__CLOCK_FENCE__';
}
}
const valueKey = options.keys[0];
const revisionKey = options.keys[writeCount];
const value = options.arguments[0];
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
const revision = Number(this.values.get(revisionKey) ?? '0') + 1;
this.values.set(valueKey, value);
for (let index = 0; index < writeCount; index += 1) {
this.values.set(options.keys[index]!, options.arguments[index]!);
}
this.values.set(revisionKey, String(revision));
return String(revision);
}
@@ -131,6 +151,8 @@ const buildContext = (options: {
develCost?: number;
currentDevelCost?: number;
rankRows?: Array<{ generalId: number; type: string; value: number }>;
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
requestId?: string;
}): GameApiContext => {
const db = {
general: {
@@ -144,12 +166,21 @@ const buildContext = (options: {
},
worldState: {
findFirst: async () => ({
clockBaseTime: new Date('2026-01-01T00:00:00.000Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
clockPhase: options.clockPhase ?? 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
tickSeconds: 60,
config: { const: { develCost: options.develCost ?? 200 } },
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
}),
},
} as unknown as DatabaseClient;
return {
requestId: options.requestId,
db,
redis: options.redis as unknown as RedisConnector['client'],
turnDaemon: options.transport,
@@ -341,6 +372,83 @@ describe('tournament router permissions and mutations', () => {
expect(transport.gold.get(general.id)).toBe(2_400);
});
it('accepts a tournament bet against the frozen game deadline while the clock is suspended', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
const context = buildContext({
redis,
transport,
generals: [general],
userId: 'user-1',
clockPhase: 'SUSPENDED',
requestId: 'http:suspended-tournament-bet',
});
const outerApiTransaction = vi.fn(async () => {
throw new Error('tournament bet must not hold an API transaction while waiting for the daemon');
});
Object.assign(context.db, { $transaction: outerApiTransaction });
const caller = appRouter.createCaller(context);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).resolves.toEqual({ ok: true });
expect(transport.gold.get(general.id)).toBe(2_400);
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
reason: 'tournamentBet',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralMeta',
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
reason: 'tournamentBet',
})
);
});
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-1', clockPhase: 'RECONCILING' })
);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
});
expect(transport.gold.get(general.id)).toBe(3_000);
expect(transport.commands).toHaveLength(0);
});
it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
@@ -394,7 +502,10 @@ describe('tournament router permissions and mutations', () => {
roles: ['admin.tournament:che:default'],
})
);
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true });
await expect(adminCaller.tournament.getAdminStatus()).resolves.toMatchObject({
ok: true,
clock: { reconciliationComplete: false, latestReconciliation: null },
});
});
it('applies the admin role boundary to every tournament mutation', async () => {
@@ -479,9 +590,7 @@ describe('tournament router permissions and mutations', () => {
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(
caller.tournament.setBettingEntries([
{ generalId: general.id, targetId: rival.id, amount: 100 },
])
caller.tournament.setBettingEntries([{ generalId: general.id, targetId: rival.id, amount: 100 }])
).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
ok: true,
@@ -41,6 +41,9 @@ integration('TournamentStore Redis source revision', () => {
keys.matchesKey,
keys.bettingKey,
keys.sourceRevisionKey,
keys.activeClockRevisionKey,
keys.deadlineGenerationKey,
keys.clockPhaseKey,
]);
if (subscriber) {
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
@@ -124,4 +127,48 @@ integration('TournamentStore Redis source revision', () => {
}),
]);
});
it('dual-writes deadline ticks and rejects a Redis clock revision race atomically', async () => {
const store = new TournamentStore(connector.client, keys);
await Promise.all([
connector.client.set(keys.activeClockRevisionKey, '7'),
connector.client.set(keys.deadlineGenerationKey, '3'),
connector.client.set(keys.clockPhaseKey, 'RUNNING'),
]);
const clockContext = {
phase: 'RUNNING' as const,
revision: 7,
deadlineGeneration: 3,
dateToTick: (date: Date) => Math.trunc(date.getTime() / 1_000),
};
const nextAt = '2026-09-03T10:00:00.000Z';
await store.withClockContext(clockContext, () =>
store.setState({
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 200,
openMonth: 1,
termSeconds: 10,
nextAt,
bettingCloseAt: '2026-09-03T09:59:50.000Z',
})
);
await expect(store.getState()).resolves.toMatchObject({
nextTick: Math.trunc(new Date(nextAt).getTime() / 1_000),
bettingCloseTick: Math.trunc(new Date('2026-09-03T09:59:50.000Z').getTime() / 1_000),
clockRevision: 7,
deadlineGeneration: 3,
});
const beforeRevision = await store.getSourceRevision();
await connector.client.set(keys.activeClockRevisionKey, '8');
await expect(
store.withClockContext(clockContext, () =>
store.setMatches([{ id: 99, stage: 7, roundIndex: 0, attackerId: 1, defenderId: 2 }])
)
).rejects.toThrow('clock revision fence failed');
await expect(store.getSourceRevision()).resolves.toBe(beforeRevision);
});
});
+7 -13
View File
@@ -5,10 +5,8 @@ import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/t
describe('turn engine status projection', () => {
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
const activeLease = {
turnDaemonLease: {
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })),
},
};
$queryRaw: vi.fn(async () => [{ running: true }]),
} as any;
const now = new Date('2026-08-24T00:00:00.000Z');
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
true
@@ -36,7 +34,7 @@ describe('turn engine status projection', () => {
await expect(
loadTurnEngineRunning(
source,
{ turnDaemonLease: { findUnique: async () => null } },
{ $queryRaw: async () => [{ running: false }] } as any,
'che:default',
now
)
@@ -44,11 +42,7 @@ describe('turn engine status projection', () => {
await expect(
loadTurnEngineRunning(
source,
{
turnDaemonLease: {
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
},
},
{ $queryRaw: async () => [{ running: false }] } as any,
'che:default',
now
)
@@ -58,10 +52,10 @@ describe('turn engine status projection', () => {
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
let now = 1_000;
const get = vi.fn(async () => 'RUNNING' as const);
const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') }));
const queryRaw = vi.fn(async () => [{ running: true }]);
const cache = new CachedTurnEngineStatus(
{ get },
{ turnDaemonLease: { findUnique } },
{ $queryRaw: queryRaw } as any,
'che:default',
2_000,
() => now
@@ -75,6 +69,6 @@ describe('turn engine status projection', () => {
now += 1;
await expect(cache.get()).resolves.toBe(true);
expect(get).toHaveBeenCalledTimes(2);
expect(findUnique).toHaveBeenCalledTimes(2);
expect(queryRaw).toHaveBeenCalledTimes(2);
});
});
+8 -25
View File
@@ -230,13 +230,7 @@ describe('vote router actor and permission boundaries', () => {
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false);
expect(
hasPollEnded(
{ closed_at: null, end_at: now, end_tick: null },
{ ...time, now: new Date(now.getTime() + 1), tick: null }
)
).toBe(true);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(true);
});
it('rejects unauthenticated survey access', async () => {
@@ -261,7 +255,6 @@ describe('vote router actor and permission boundaries', () => {
voteId: 1,
generalId: 7,
selection: [0],
acceptedGameTick: 100,
});
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false
@@ -301,8 +294,6 @@ describe('vote router actor and permission boundaries', () => {
const auth = buildAuth(['admin.survey.open']);
const fixture = buildContext({ auth });
const caller = appRouter.createCaller(fixture.context);
const windowStart = Date.now();
await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
await expect(
caller.vote.createPoll({
@@ -314,8 +305,6 @@ describe('vote router actor and permission boundaries', () => {
).resolves.toEqual({ ok: true });
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true });
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
const windowEnd = Date.now();
const mutationQueries = fixture.queryRaw.mock.calls
.map(([query]) => query)
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query)));
@@ -325,30 +314,24 @@ describe('vote router actor and permission boundaries', () => {
const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => {
const expectDbWallClock = (query: GamePrisma.Sql | undefined): void => {
expect(query).toBeDefined();
const value = query?.values.at(index);
expect(value).toBeInstanceOf(Date);
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
return value as Date;
expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
};
expect(sqlText(commentInsert!)).toContain('created_at');
expectCurrentDateAt(commentInsert, -1);
expectDbWallClock(commentInsert);
expect(sqlText(pollInsert!)).toContain('created_at');
expect(sqlText(pollInsert!)).toContain('updated_at');
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2);
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
expect(pollUpdatedAt).toBe(pollCreatedAt);
expectDbWallClock(pollInsert);
expect(pollUpdates).toHaveLength(3);
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt);
expectDbWallClock(closePreviousUpdate);
expect(sqlText(editPollUpdate!)).toContain('updated_at');
expectCurrentDateAt(editPollUpdate, -2);
expectDbWallClock(editPollUpdate);
expect(sqlText(closePollUpdate!)).toContain('updated_at');
expectCurrentDateAt(closePollUpdate, -2);
expectDbWallClock(closePollUpdate);
});
it('reports the current world develcost as the legacy five-times survey reward', async () => {
+8
View File
@@ -42,6 +42,14 @@
"types": "./dist/turn/databaseHooks.d.ts",
"default": "./dist/turn/databaseHooks.js"
},
"./turn/clockReconciliation.js": {
"types": "./dist/turn/clockReconciliation.d.ts",
"default": "./dist/turn/clockReconciliation.js"
},
"./turn/clockProjectionOutbox.js": {
"types": "./dist/turn/clockProjectionOutbox.d.ts",
"default": "./dist/turn/clockProjectionOutbox.js"
},
"./turn/inMemoryWorld.js": {
"types": "./dist/turn/inMemoryWorld.d.ts",
"default": "./dist/turn/inMemoryWorld.js"
+46 -22
View File
@@ -60,27 +60,27 @@ export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null },
now: Date,
nowTick: number | null
): boolean =>
auction.closeTick !== null && nowTick !== null
? auction.closeTick < BigInt(nowTick)
: auction.closeAt.getTime() < now.getTime();
): boolean => {
void now;
return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
};
export const resolveAuctionBidTiming = (
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
processingNow: Date,
acceptedGameTick?: number
): { bidAt: Date; bidTick: number } =>
acceptedGameTick === undefined
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) }
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick };
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingGameTick: number
): { bidAt: Date; bidTick: number } => {
if (!Number.isSafeInteger(processingGameTick)) {
throw new Error('Auction bid requires an authoritative daemon processing game tick.');
}
return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
};
export const hasAuctionBidClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null },
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
processingNow: Date,
acceptedGameTick?: number
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingGameTick: number
): boolean => {
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick);
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
return hasAuctionClosePassed(auction, bidAt, bidTick);
};
@@ -268,8 +268,15 @@ export const createAuctionBidder = async (options: {
reason: '경매가 종료되었습니다.',
};
}
const processingNow = world.getGameNow(new Date());
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, command.acceptedGameTick);
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
throw new Error('auctionBid requires an authoritative daemon processing game tick.');
}
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
throw new Error('auctionBid requires its durable input-event wall occurrence.');
}
const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick);
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
return {
type: 'auctionBid',
@@ -503,13 +510,24 @@ export const createAuctionBidder = async (options: {
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
await tx.$executeRaw(
GamePrisma.sql`
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
INSERT INTO auction_bid (
auction_id,
general_id,
amount,
event_id,
event_at,
occurred_game_tick,
requested_at_wall,
meta
)
VALUES (
${command.auctionId},
${command.generalId},
${command.amount},
${eventId},
${eventAt},
${BigInt(bidTick)},
${requestedAtWall},
${JSON.stringify({
tryExtendCloseDate: command.tryExtendCloseDate ?? true,
...(auction.type === 'UNIQUE_ITEM'
@@ -529,7 +547,7 @@ export const createAuctionBidder = async (options: {
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
latest_event_id = ${eventId},
latest_event_at = ${eventAt},
updated_at = ${eventAt}
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${command.auctionId}
AND status = 'OPEN'
AND latest_event_id = ${auction.latestEventId}
@@ -549,7 +567,7 @@ export const createAuctionBidder = async (options: {
GamePrisma.sql`
UPDATE inheritance_point
SET value = value - ${morePoint},
updated_at = ${eventAt}
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE user_id = ${userId}
AND key = 'previous'
AND value >= ${morePoint}
@@ -580,11 +598,16 @@ export const createAuctionBidder = async (options: {
await tx.$executeRaw(
GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at)
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt})
VALUES (
${prevUserId},
'previous',
${highestBid.amount},
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT (user_id, key)
DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
`
);
await tx.$executeRaw(
@@ -704,6 +727,7 @@ export const createAuctionBidder = async (options: {
ok: true,
auctionId: command.auctionId,
closeAt: nextCloseAt.toISOString(),
closeTick: world.dateToGameTick(nextCloseAt),
};
},
close: async (): Promise<void> => {
+15 -16
View File
@@ -91,21 +91,17 @@ export const isAuctionFinalizeGenerationCurrent = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
): boolean => {
if (command.expectedCloseTick !== undefined) {
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
}
if (command.expectedCloseAt !== undefined) {
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
}
return true;
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
};
export const hasAuctionFinalizeDeadlineArrived = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
now: Date,
nowTick: number
): boolean =>
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick);
): boolean => {
void now;
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
};
export const buildAuctionBidderSystemMessage = (options: {
bidder: TurnGeneral;
@@ -293,7 +289,11 @@ export const createAuctionFinalizer = async (options: {
return { type: 'auctionFinalize', ok: true, auctionId };
}
const now = world.getGameNow(new Date());
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionFinalize requires an authoritative daemon processing game tick.');
}
const now = world.gameTickToDate(processingGameTick);
if (auction.status === 'OPEN') {
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
return {
@@ -303,8 +303,7 @@ export const createAuctionFinalizer = async (options: {
reason: '경매 마감 세대가 변경되었습니다.',
};
}
const nowTick = world.dateToGameTick(now);
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
return {
type: 'auctionFinalize',
ok: false,
@@ -316,8 +315,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql`
UPDATE auction
SET status = 'FINALIZING',
finalizing_at = ${now},
updated_at = ${now}
finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId}
AND status = 'OPEN'
`
@@ -364,8 +363,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql`
UPDATE auction
SET status = ${status},
finished_at = ${now},
updated_at = ${now}
finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId}
`
);
+20 -4
View File
@@ -94,7 +94,11 @@ const openResourceAuction = async (
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
}
const now = world.getGameNow(new Date());
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
}
const now = world.gameTickToDate(processingGameTick);
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
const auction = await db.auction.create({
@@ -113,7 +117,7 @@ const openResourceAuction = async (
},
status: 'OPEN',
closeAt,
openTick: BigInt(world.dateToGameTick(now)),
openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)),
},
});
@@ -125,6 +129,7 @@ const openResourceAuction = async (
ok: true,
auctionId: auction.id,
closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
};
};
@@ -220,8 +225,16 @@ const openUniqueAuction = async (
}
const state = world.getState();
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
}
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
throw new Error('auctionOpen requires its durable input-event wall occurrence.');
}
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
const now = world.getGameNow(new Date());
const now = world.gameTickToDate(processingGameTick);
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
const extensionLimitMinutes = Math.max(
@@ -253,7 +266,7 @@ const openUniqueAuction = async (
},
status: 'OPEN',
closeAt,
openTick: BigInt(world.dateToGameTick(now)),
openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)),
latestEventId: eventId,
latestEventAt: now,
@@ -263,6 +276,8 @@ const openUniqueAuction = async (
amount: command.amount,
eventId,
eventAt: now,
occurredGameTick: BigInt(processingGameTick),
requestedAtWall,
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
},
},
@@ -308,6 +323,7 @@ const openUniqueAuction = async (
ok: true,
auctionId: auction.id,
closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
};
};
+2
View File
@@ -20,6 +20,8 @@ export * from './turn/engineStateManager.js';
export * from './turn/inMemoryStateStore.js';
export * from './turn/inMemoryTurnProcessor.js';
export * from './turn/databaseHooks.js';
export * from './turn/clockReconciliation.js';
export * from './turn/clockProjectionOutbox.js';
export * from './turn/joinCreateGeneralService.js';
export * from './turn/npcPossessionService.js';
export * from './turn/selectPoolService.js';
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { performance } from 'node:perf_hooks';
import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra';
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
import type {
@@ -10,8 +11,9 @@ import type {
TurnDaemonStatus,
} from './types.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const serializeResult = (value: unknown): string =>
JSON.stringify(value, (_key, item: unknown) => (typeof item === 'bigint' ? item.toString() : item)) ?? 'null';
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
private readonly localQueue: TurnDaemonCommand[] = [];
@@ -35,8 +37,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
return local.concat(remote);
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
while (deadlineMs === null || Date.now() < deadlineMs) {
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs);
while (deadline === null || performance.now() < deadline) {
const local = this.localQueue.shift();
if (local) {
return local;
@@ -45,7 +48,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
if (remote[0]) {
return remote[0];
}
const remaining = deadlineMs === null ? 100 : Math.max(1, Math.min(100, deadlineMs - Date.now()));
const remaining = deadline === null ? 100 : Math.max(1, Math.min(100, deadline - performance.now()));
await delay(remaining);
}
return null;
@@ -84,30 +87,50 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
return;
}
const terminal = event.attempts >= this.maxAttempts;
await transaction.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
attempts: event.attempts,
},
data: {
status: terminal ? 'FAILED' : 'PENDING',
processingAt: null,
lockedBy: null,
leaseUntil: null,
completedAt: terminal ? new Date() : null,
result: GamePrisma.DbNull,
error: message,
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus",
processing_at = NULL,
locked_by = NULL,
lease_until = NULL,
completed_at = CASE
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
ELSE NULL
END,
result = NULL,
error = ${message}
WHERE request_id = ${requestId}
AND target = 'ENGINE'::"InputEventTarget"
AND status = 'PROCESSING'::"InputEventStatus"
AND locked_by = ${this.workerId}
AND attempts = ${event.attempts}
`);
});
}
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
await this.recoverExpiredLeases();
return this.db.$transaction(async (transaction) => {
const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
});
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
const currentRevision = world?.clockRevision ?? null;
const maintenanceSuspended =
world?.clockPhase === 'SUSPENDED' &&
Boolean(
await transaction.clockSuspension.findFirst({
where: {
status: 'SUSPENDED',
source: 'MAINTENANCE',
sourceRevision: world.clockRevision,
},
select: { id: true },
})
);
const rows = await transaction.$queryRaw<
Array<{
sequence: bigint;
@@ -115,6 +138,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
eventType: string;
payload: unknown;
createdAt: Date;
acceptedGameTick: bigint | null;
acceptedClockRevision: bigint | null;
acceptedDeadlineGeneration: bigint | null;
}>
>(GamePrisma.sql`
SELECT
@@ -122,10 +148,71 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
"request_id" AS "requestId",
"event_type" AS "eventType",
"payload",
"created_at" AS "createdAt"
"created_at" AS "createdAt",
"accepted_game_tick" AS "acceptedGameTick",
"accepted_clock_revision" AS "acceptedClockRevision",
"accepted_deadline_generation" AS "acceptedDeadlineGeneration"
FROM "input_event"
WHERE "target" = 'ENGINE'::"InputEventTarget"
AND "status" = 'PENDING'::"InputEventStatus"
AND (
${gameplayAllowed}
OR "event_type" = 'getStatus'
OR (
${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
)
OR (
${maintenanceSuspended}
AND "event_type" IN (
'inheritanceAction',
'dropItem',
'changePermission',
'appoint',
'setNationSetting',
'setNpcPolicy',
'shiftSchedule'
)
)
OR (
${maintenanceSuspended}
AND "event_type" = 'messageRespond'
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
AND EXISTS (
SELECT 1
FROM "message_action" AS pending_action
WHERE pending_action."message_id" = ("input_event"."payload" ->> 'messageId')::integer
AND pending_action."status" = 'PENDING'
AND pending_action."clock_revision" = ${currentRevision}
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
)
)
OR (
${world?.clockPhase === 'SUSPENDED'}
AND "event_type" = 'messageRespond'
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
AND EXISTS (
SELECT 1
FROM "message" AS pending_message
JOIN "message_action" AS pending_action
ON pending_action."message_id" = pending_message."id"
WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer
AND pending_message."message" #>> '{option,action}' = 'raiseInvader'
AND pending_action."action_type" = 'raiseInvader'
AND pending_action."status" = 'PENDING'
AND pending_action."clock_revision" = ${currentRevision}
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
)
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}
@@ -133,41 +220,93 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
if (rows.length === 0) {
return [];
}
await transaction.inputEvent.updateMany({
where: {
sequence: { in: rows.map((row) => row.sequence) },
target: 'ENGINE',
status: 'PENDING',
},
data: {
status: 'PROCESSING',
processingAt: new Date(),
lockedBy: this.workerId,
leaseUntil: new Date(Date.now() + this.leaseDurationMs),
attempts: { increment: 1 },
},
});
const appliedSuspensions = currentRevision
? await transaction.clockSuspension.findMany({
where: { status: 'APPLIED', targetRevision: { lte: currentRevision } },
orderBy: { sourceRevision: 'asc' },
select: { sourceRevision: true, targetRevision: true, shiftTicks: true },
})
: [];
const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => {
if (row.eventType === 'getStatus') return row.acceptedGameTick ?? claimCoordinate.gameTick;
if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) {
return claimCoordinate.gameTick;
}
if (row.acceptedClockRevision > currentRevision) return undefined;
let revision = row.acceptedClockRevision;
let tick = row.acceptedGameTick;
while (revision < currentRevision) {
const step = appliedSuspensions.find((entry) => entry.sourceRevision === revision);
if (!step || step.shiftTicks === null || step.targetRevision !== revision + 1n) return undefined;
tick += step.shiftTicks;
revision = step.targetRevision;
}
return tick;
};
const processableRows = rows
.map((row) => ({ row, processingGameTick: convertTick(row) }))
.filter(
(entry): entry is { row: (typeof rows)[number]; processingGameTick: bigint | null } =>
entry.processingGameTick !== undefined
);
for (const { row, processingGameTick } of processableRows) {
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'PROCESSING'::"InputEventStatus",
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
accepted_game_tick = COALESCE(accepted_game_tick, ${processingGameTick}),
accepted_clock_revision = COALESCE(accepted_clock_revision, ${currentRevision}),
accepted_deadline_generation = COALESCE(
accepted_deadline_generation,
${world?.deadlineGeneration ?? null}
),
processing_game_tick = ${processingGameTick},
processing_clock_revision = ${currentRevision},
processing_deadline_generation = ${world?.deadlineGeneration ?? null},
locked_by = ${this.workerId},
lease_until = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${this.leaseDurationMs} * INTERVAL '1 millisecond',
attempts = attempts + 1
WHERE sequence = ${row.sequence}
`);
}
const commands: TurnDaemonCommand[] = [];
for (const row of rows) {
for (const { row, processingGameTick } of processableRows) {
const command = normalizeTurnDaemonCommand({
requestId: row.requestId,
sentAt: row.createdAt.toISOString(),
command: row.payload as TurnDaemonCommand,
});
if (!command) {
await transaction.inputEvent.update({
where: { sequence: row.sequence },
data: {
status: 'FAILED',
error: `Invalid command payload for ${row.eventType}`,
completedAt: new Date(),
lockedBy: null,
leaseUntil: null,
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'FAILED'::"InputEventStatus",
error = ${`Invalid command payload for ${row.eventType}`},
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
locked_by = NULL,
lease_until = NULL
WHERE sequence = ${row.sequence}
`);
continue;
}
if (processingGameTick !== null) {
const value = Number(processingGameTick);
if (!Number.isSafeInteger(value)) {
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'FAILED'::"InputEventStatus",
error = 'Converted processing game tick is outside the safe integer range.',
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
locked_by = NULL,
lease_until = NULL
WHERE sequence = ${row.sequence}
`);
continue;
}
Reflect.set(command, 'processingGameTick', value);
}
Reflect.set(command, 'requestedAtWall', row.createdAt);
commands.push(command);
}
return commands;
@@ -175,23 +314,20 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
}
private async complete(requestId: string, result: unknown): Promise<void> {
const completed = await this.db.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
},
data: {
status: 'SUCCEEDED',
result: asJson(result),
completedAt: new Date(),
error: null,
lockedBy: null,
leaseUntil: null,
},
});
if (completed.count > 0) {
const completed = await this.db.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'SUCCEEDED'::"InputEventStatus",
result = CAST(${serializeResult(result)} AS jsonb),
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
error = NULL,
locked_by = NULL,
lease_until = NULL
WHERE request_id = ${requestId}
AND target = 'ENGINE'::"InputEventTarget"
AND status = 'PROCESSING'::"InputEventStatus"
AND locked_by = ${this.workerId}
`);
if (completed > 0) {
return;
}
// Database hooks commit mutation results atomically with game state and
@@ -212,19 +348,15 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
}
private async recoverExpiredLeases(): Promise<void> {
const now = new Date();
await this.db.inputEvent.updateMany({
where: {
target: 'ENGINE',
status: 'PROCESSING',
leaseUntil: { lt: now },
},
data: {
status: 'PENDING',
processingAt: null,
lockedBy: null,
leaseUntil: null,
},
});
await this.db.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'PENDING'::"InputEventStatus",
processing_at = NULL,
locked_by = NULL,
lease_until = NULL
WHERE target = 'ENGINE'::"InputEventTarget"
AND status = 'PROCESSING'::"InputEventStatus"
AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
`);
}
}
@@ -86,9 +86,9 @@ export class DatabaseTurnDaemonLease {
VALUES (
${this.profile},
${this.ownerId},
CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
1,
CURRENT_TIMESTAMP
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT ("profile") DO UPDATE
SET
@@ -99,10 +99,10 @@ export class DatabaseTurnDaemonLease {
THEN "turn_daemon_lease"."fencing_epoch"
ELSE "turn_daemon_lease"."fencing_epoch" + 1
END,
"heartbeat_at" = CURRENT_TIMESTAMP
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE
"turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
RETURNING "profile", "owner_id", "fencing_epoch"
`);
const row = rows[0];
@@ -141,13 +141,13 @@ export class DatabaseTurnDaemonLease {
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET
"lease_until" = CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
"heartbeat_at" = CURRENT_TIMESTAMP
"lease_until" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP
AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
RETURNING "profile", "owner_id", "fencing_epoch"
`);
if (rows.length === 0) {
@@ -177,7 +177,7 @@ export class DatabaseTurnDaemonLease {
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP
AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
FOR UPDATE
`);
if (rows.length === 0) {
@@ -196,7 +196,8 @@ export class DatabaseTurnDaemonLease {
}
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET "lease_until" = CURRENT_TIMESTAMP, "heartbeat_at" = CURRENT_TIMESTAMP
SET "lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
@@ -1,7 +1,7 @@
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
type Waiter = {
deadlineMs: number | null;
timeoutMs: number | null;
resolve: (command: TurnDaemonCommand | null) => void;
timeoutId?: ReturnType<typeof setTimeout>;
};
@@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue {
return drained;
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
if (this.queue.length > 0) {
return this.queue.shift() ?? null;
}
return new Promise((resolve) => {
const waiter: Waiter = { deadlineMs, resolve };
if (deadlineMs !== null) {
const delay = Math.max(0, deadlineMs - Date.now());
const waiter: Waiter = { timeoutMs, resolve };
if (timeoutMs !== null) {
const delay = Math.max(0, timeoutMs);
waiter.timeoutId = setTimeout(() => {
this.removeWaiter(waiter);
resolve(null);
@@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur
return drained.concat(remote);
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
if (this.localQueue.length > 0) {
return this.localQueue.shift() ?? null;
}
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now());
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000);
if (deadlineMs !== null && blockMs === 0) {
const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs);
const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000);
if (timeoutMs !== null && blockMs === 0) {
return null;
}
@@ -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
@@ -209,7 +222,7 @@ export class TurnDaemonLifecycle {
continue;
}
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs));
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
if (command) {
await this.handleCommand(command);
}
@@ -255,7 +268,7 @@ export class TurnDaemonLifecycle {
}
private async waitForResume(): Promise<void> {
const command = await this.controlQueue.waitUntil(null);
const command = await this.controlQueue.waitFor(null);
if (command) {
await this.handleCommand(command);
}
+16 -3
View File
@@ -7,7 +7,7 @@ import type {
TurnRunResult,
} from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import type { GameClockMode } from '@sammo-ts/common';
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
export type {
RunReason,
@@ -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 {
@@ -60,7 +66,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>;
@@ -69,7 +82,7 @@ export interface TurnStateStore {
export interface TurnDaemonControlQueue {
enqueue(command: TurnDaemonCommand): void;
drain(): Promise<TurnDaemonCommand[]>;
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>;
waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null>;
getDepth(): number;
}
+34 -9
View File
@@ -6,7 +6,7 @@ import {
type InputJsonValue,
type TurnEngineEventCreateManyInput,
} from '@sammo-ts/infra';
import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common';
import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common';
import {
buildScenarioBootstrap,
resolveScenarioGeneralDeathMonth,
@@ -94,6 +94,17 @@ export const calculateInitialTurnTick = (
return clock.addTicks(baseTick, offsetTicks);
};
export const resolveInitialClockPhase = (
mode: GameClockMode,
seededAtWall: Date,
scheduledOpenAtWall: Date
): GameClockPhase => {
if (mode === 'manual') {
return 'MANUAL';
}
return scheduledOpenAtWall.getTime() > seededAtWall.getTime() ? 'PREOPEN' : 'RUNNING';
};
const formatDateTime = (date: Date): string => {
const pad = (value: number): string => String(value).padStart(2, '0');
return [
@@ -234,14 +245,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands.
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
const initialClock = new GameClock({
baseTime: startState.startTime,
tick: 0,
mode: gameClockMode,
wallAnchor: initialClockWallAnchor,
turnSeconds: tickSeconds,
phase: initialClockPhase,
revision: 1,
});
const initialClockTick = initialClock.dateToTick(now);
// The formal opening wall instant is always logical tick zero. PREOPEN may
// project signed negative observed ticks, but executable seed schedules are
// derived from this zero coordinate rather than from the seed wall time.
const initialClockTick = 0;
const { seed, warnings } = buildScenarioBootstrap({
scenario: scenarioDefinition,
@@ -327,6 +344,10 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
}
worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = now.toISOString();
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
worldMeta.calendarStart = startState.startTime.toISOString();
if (install?.preopenAt) {
worldMeta.preopenAt = formatDateTime(install.preopenAt);
@@ -420,6 +441,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
clockMode: gameClockMode,
clockWallAnchor: initialClock.wallAnchor,
lastTurnTick: BigInt(initialClockTick),
clockPhase: initialClockPhase,
clockRevision: 1n,
deadlineGeneration: 1n,
config: asJson({ ...scenarioConfig, ...worldConfig }),
meta: asJson(worldMeta),
},
@@ -590,13 +614,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
weaponCode: general.weapon ?? 'None',
bookCode: general.book ?? 'None',
itemCode: general.item ?? 'None',
turnTime: new Date(
now.getTime() +
Math.floor(
(typeof general.meta.initialTurnOffsetMicros === 'number'
? general.meta.initialTurnOffsetMicros
: 0) / 1_000
)
turnTime: initialClock.tickToDate(
calculateInitialTurnTick(
initialClock,
initialClockTick,
typeof general.meta.initialTurnOffsetMicros === 'number'
? general.meta.initialTurnOffsetMicros
: 0
)
),
turnTick: BigInt(
calculateInitialTurnTick(
@@ -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';
@@ -17,6 +18,10 @@ interface MessageRow {
type: string;
time: Date;
validUntil: Date;
actionType: string;
actionStatus: string;
createdGameTick: bigint;
expiresGameTick: bigint | null;
message: unknown;
}
@@ -93,15 +98,21 @@ const invalidateMessageIds = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
ids: number[],
now: Date
now: Date,
authoritativeGameTick?: bigint
): Promise<void> => {
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
if (uniqueIds.length === 0) return;
const resolvedGameTick = authoritativeGameTick ?? BigInt(world.dateToGameTick(now));
await db.messageAction.updateMany({
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick },
});
await db.message.updateMany({
where: { id: { in: uniqueIds } },
data: {
validUntil: now,
validUntilTick: BigInt(world.dateToGameTick(now)),
validUntilTick: resolvedGameTick,
},
});
};
@@ -112,40 +123,53 @@ const validateActor = async (options: {
requestId?: string;
userId: string;
generalId: number;
}): Promise<Date> => {
}): Promise<{ processingGameTick: number }> => {
const actor = options.world.getGeneralById(options.generalId);
if (!actor || actor.userId !== options.userId) {
throw new Error('messageRespond general owner does not match command user.');
}
if (!options.requestId) return new Date();
if (!options.requestId) {
throw new Error('messageRespond requires a durable ENGINE input event requestId.');
}
const event = await options.db.inputEvent.findUnique({
where: { requestId: options.requestId },
select: { actorUserId: true, target: true, eventType: true, createdAt: true },
select: { actorUserId: true, target: true, eventType: true, processingGameTick: true },
});
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
throw new Error('ENGINE input event actor or type does not match messageRespond.');
}
return event.createdAt;
const processingGameTick = event.processingGameTick;
if (processingGameTick === null || !Number.isSafeInteger(Number(processingGameTick))) {
throw new Error('messageRespond requires an authoritative processing game tick.');
}
return { processingGameTick: Number(processingGameTick) };
};
const fetchMessageForUpdate = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
messageId: number,
now: Date
currentGameTick: number
): Promise<MessageRow | null> => {
const currentTick = BigInt(world.dateToGameTick(now));
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
FROM message
WHERE id = ${messageId}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
OR (valid_until_tick IS NULL AND valid_until > ${now})
)
SELECT
envelope.id,
envelope.mailbox,
envelope.type,
envelope.time,
envelope.valid_until AS "validUntil",
action.action_type AS "actionType",
action.status AS "actionStatus",
action.created_game_tick AS "createdGameTick",
action.expires_game_tick AS "expiresGameTick",
envelope.message
FROM message AS envelope
JOIN message_action AS action ON action.message_id = envelope.id
WHERE envelope.id = ${messageId}
AND action.status = 'PENDING'
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(currentGameTick)})
LIMIT 1
FOR UPDATE
FOR UPDATE OF envelope, action
`);
return rows[0] ?? null;
};
@@ -164,7 +188,7 @@ const respondToScout = async (options: {
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
}
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) {
if (row.actionStatus !== 'PENDING' || row.actionType !== 'scout' || isLegacyTruthy(asRecord(payload.option).used)) {
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
}
@@ -185,18 +209,17 @@ const respondToScout = async (options: {
}
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
SELECT id
FROM message
WHERE mailbox = ${payload.src.generalId}
AND type = 'private'
AND dest = mailbox
AND id <> ${row.id}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
OR (valid_until_tick IS NULL AND valid_until > ${now})
)
AND message->'option'->>'action' = 'scout'
FOR UPDATE
SELECT envelope.id
FROM message AS envelope
JOIN message_action AS action ON action.message_id = envelope.id
WHERE envelope.mailbox = ${payload.src.generalId}
AND envelope.type = 'private'
AND envelope.dest = envelope.mailbox
AND envelope.id <> ${row.id}
AND action.status = 'PENDING'
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
AND action.action_type = 'scout'
FOR UPDATE OF envelope, action
`);
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
world.queueMessage({
@@ -251,6 +274,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 +307,22 @@ 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 alignedState = world.getState();
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,22 +332,44 @@ 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(
args,
{
year: state.currentYear,
month: state.currentMonth,
startyear: asNumber(state.meta.startYear, state.currentYear),
year: alignedState.currentYear,
month: alignedState.currentMonth,
startyear: asNumber(alignedState.meta.startYear, alignedState.currentYear),
currentEventID: 0,
// Ref uses the frozen game_env.turntime while unification is paused.
// `now` is the realtime game projection and can be hours ahead after
// a long response wait, delaying every newly summoned invader turn.
turnTime: state.lastTurnTime,
// The exact reconciliation snapshot is the authority even when the
// turn rate stays unchanged. Using the pre-reconciliation cursor here
// would create immediately overdue invader turns after a long wait.
turnTime: alignedState.lastTurnTime,
},
event
);
const resolvedGameTick = BigInt(alignment.alignedTick);
if (resolvedGameTick < row.createdGameTick) {
throw new Error(
`RaiseInvader resolved tick ${resolvedGameTick} precedes prompt tick ${row.createdGameTick}; clock authority is inconsistent.`
);
}
const promptRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
SELECT message_id AS id
FROM message_action
WHERE action_type = 'raiseInvader'
AND status = 'PENDING'
AND created_game_tick = ${row.createdGameTick}
FOR UPDATE
`);
await invalidateMessageIds(
db,
world,
promptRows.map(({ id }) => id),
world.gameTickToDate(alignment.alignedTick),
resolvedGameTick
);
return { ok: true, action: 'raiseInvader', reason: 'success' };
};
@@ -311,14 +384,27 @@ 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);
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
const accepted = await validateActor(options);
const now = options.world.gameTickToDate(accepted.processingGameTick);
const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
const payload = parsePayload(row.message);
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
const action = asRecord(payload.option).action;
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
if (action === 'scout') {
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
}
@@ -0,0 +1,341 @@
import { createHash } from 'node:crypto';
import { GameClock, parseGameClockPhase } from '@sammo-ts/common';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
export interface ClockProjectionRedis {
get(key: string): Promise<string | null>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
}
interface ClaimedOutboxRow {
id: bigint;
}
interface DbWallRow {
wallNow: Date;
}
interface ProjectionPayload {
version: 1;
profileName: string;
suspensionId: string;
sourceRevision: number;
targetRevision: number;
deadlineGeneration: number;
shiftTicks: number;
projectionDeltaMilliseconds: number;
clockBaseTime: string;
ticksPerSecond: number;
}
interface TournamentProjectionState {
stage?: number;
nextAt?: string;
nextTick?: number;
bettingCloseAt?: string;
bettingCloseTick?: number;
clockRevision?: number;
deadlineGeneration?: number;
[key: string]: unknown;
}
const APPLY_CLOCK_PROJECTION_SCRIPT = `
local active = redis.call('GET', KEYS[1])
if active == ARGV[2] then
if redis.call('GET', KEYS[5]) == ARGV[4] and redis.call('GET', KEYS[2]) == ARGV[3] then
return 2
end
return -3
end
if active and active ~= ARGV[1] then
return -1
end
if ARGV[5] ~= '__NONE__' and redis.call('GET', KEYS[4]) ~= ARGV[5] then
return -2
end
redis.call('DEL', KEYS[3])
local count = tonumber(ARGV[7])
local offset = 8
for index = 1, count do
redis.call('ZADD', KEYS[3], ARGV[offset], ARGV[offset + 1])
offset = offset + 2
end
if ARGV[5] ~= '__NONE__' then
redis.call('SET', KEYS[4], ARGV[6])
end
redis.call('SET', KEYS[1], ARGV[2])
redis.call('SET', KEYS[2], ARGV[3])
redis.call('SET', KEYS[5], ARGV[4])
redis.call('SET', KEYS[6], 'RUNNING')
return 1
`;
const safeInteger = (value: unknown, label: string): number => {
const result = typeof value === 'bigint' ? Number(value) : value;
if (typeof result !== 'number' || !Number.isSafeInteger(result)) {
throw new Error(`${label} must be a safe integer.`);
}
return result;
};
const canonicalize = (value: unknown): unknown => {
if (typeof value === 'bigint') return value.toString();
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)])
);
}
return value;
};
const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value));
const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex');
const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
const rows = await db.$queryRaw<DbWallRow[]>(GamePrisma.sql`
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow"
`);
if (!rows[0]?.wallNow) throw new Error('Failed to read PostgreSQL wall time for the projection outbox.');
return rows[0].wallNow;
};
const parsePayload = (value: GamePrisma.JsonValue): ProjectionPayload => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Clock projection outbox payload must be an object.');
}
const payload = value as Record<string, unknown>;
if (payload.version !== 1 || typeof payload.profileName !== 'string' || typeof payload.suspensionId !== 'string') {
throw new Error('Clock projection outbox payload identity is invalid.');
}
if (typeof payload.clockBaseTime !== 'string') {
throw new Error('Clock projection outbox is missing its projection base.');
}
return {
version: 1,
profileName: payload.profileName,
suspensionId: payload.suspensionId,
sourceRevision: safeInteger(payload.sourceRevision, 'sourceRevision'),
targetRevision: safeInteger(payload.targetRevision, 'targetRevision'),
deadlineGeneration: safeInteger(payload.deadlineGeneration, 'deadlineGeneration'),
shiftTicks: safeInteger(payload.shiftTicks, 'shiftTicks'),
projectionDeltaMilliseconds: safeInteger(payload.projectionDeltaMilliseconds, 'projectionDeltaMilliseconds'),
clockBaseTime: payload.clockBaseTime,
ticksPerSecond: safeInteger(payload.ticksPerSecond, 'ticksPerSecond'),
};
};
const claimNext = async (db: GamePrismaClient, workerId: string) =>
db.$transaction(async (transaction) => {
const rows = await transaction.$queryRaw<ClaimedOutboxRow[]>(GamePrisma.sql`
SELECT id
FROM clock_projection_outbox
WHERE available_at <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
AND (
status IN ('PENDING', 'FAILED')
OR (status = 'APPLYING' AND locked_at < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds')
)
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
`);
const id = rows[0]?.id;
if (id === undefined) return null;
const lockedAt = await readDbWall(transaction);
return transaction.clockProjectionOutbox.update({
where: { id },
data: {
status: 'APPLYING',
attempts: { increment: 1 },
lockedAt,
lockedBy: workerId,
lastError: null,
},
});
});
const projectTournamentState = (
raw: string | null,
payload: ProjectionPayload,
clock: GameClock
): { expected: string; next: string } | null => {
if (!raw) return null;
const parsed = JSON.parse(raw) as TournamentProjectionState;
const active = typeof parsed.stage === 'number' && parsed.stage > 0;
if (active && parsed.nextAt && !Number.isSafeInteger(parsed.nextTick)) {
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
}
if (active && parsed.bettingCloseAt && !Number.isSafeInteger(parsed.bettingCloseTick)) {
throw new Error('Active tournament bettingCloseAt lacks the authoritative bettingCloseTick dual-write.');
}
const next: TournamentProjectionState = {
...parsed,
clockRevision: payload.targetRevision,
deadlineGeneration: payload.deadlineGeneration,
};
if (Number.isSafeInteger(parsed.nextTick)) {
next.nextTick = parsed.nextTick! + payload.shiftTicks;
next.nextAt = clock.tickToDate(next.nextTick).toISOString();
}
if (Number.isSafeInteger(parsed.bettingCloseTick)) {
next.bettingCloseTick = parsed.bettingCloseTick! + payload.shiftTicks;
next.bettingCloseAt = clock.tickToDate(next.bettingCloseTick).toISOString();
}
return { expected: raw, next: JSON.stringify(next) };
};
const recordFailure = async (db: GamePrismaClient, outboxId: bigint, error: unknown): Promise<void> => {
const message = error instanceof Error ? error.message : String(error);
await db.$executeRaw(GamePrisma.sql`
UPDATE clock_projection_outbox
SET status = 'FAILED',
locked_at = NULL,
locked_by = NULL,
last_error = ${message.slice(0, 4_000)},
available_at = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '1 second'
WHERE id = ${outboxId} AND status = 'APPLYING'
`);
};
export const applyNextClockProjection = async (options: {
db: GamePrismaClient;
redis: ClockProjectionRedis;
workerId: string;
}): Promise<'IDLE' | 'APPLIED' | 'RECOVERED'> => {
if (!options.workerId.trim()) throw new Error('Clock projection worker ID is required.');
const outbox = await claimNext(options.db, options.workerId);
if (!outbox) return 'IDLE';
try {
const payload = parsePayload(outbox.payload);
if (checksum(outbox.payload) !== outbox.checksum) {
throw new Error('Clock projection outbox checksum verification failed.');
}
if (outbox.targetRevision !== BigInt(payload.targetRevision)) {
throw new Error('Clock projection payload revision differs from its outbox row.');
}
const world = await options.db.worldState.findUniqueOrThrow({ where: { id: outbox.worldStateId } });
if (
parseGameClockPhase(world.clockPhase) !== 'RECONCILING' ||
world.clockRevision !== outbox.targetRevision ||
world.deadlineGeneration !== BigInt(payload.deadlineGeneration) ||
!world.clockBaseTime
) {
throw new Error('Clock projection DB phase/revision/generation fence failed.');
}
const clock = new GameClock({
baseTime: new Date(payload.clockBaseTime),
tick: safeInteger(world.clockTick, 'world clock tick'),
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: world.clockWallAnchor ?? new Date(),
turnSeconds: world.tickSeconds,
phase: 'RECONCILING',
revision: payload.targetRevision,
});
if (clock.ticksPerSecond !== payload.ticksPerSecond) {
throw new Error('Clock projection rate differs from the durable outbox payload.');
}
const auctions = await options.db.auction.findMany({
where: { status: { in: ['OPEN', 'FINALIZING'] } },
orderBy: { id: 'asc' },
select: { id: true, closeTick: true },
});
const timers = auctions.map((auction) => {
if (auction.closeTick === null) {
throw new Error(`Active auction ${auction.id} lacks closeTick during projection rebuild.`);
}
return { score: safeInteger(auction.closeTick, `auction ${auction.id} closeTick`), id: String(auction.id) };
});
const prefix = `sammo:${payload.profileName}`;
const tournamentKey = `${prefix}:tournament:state`;
const tournament = projectTournamentState(await options.redis.get(tournamentKey), payload, clock);
const result = await options.redis.eval(APPLY_CLOCK_PROJECTION_SCRIPT, {
keys: [
`${prefix}:clock:active-revision`,
`${prefix}:clock:deadline-generation`,
`${prefix}:auction:timer`,
tournamentKey,
`${prefix}:clock:projection-checksum`,
`${prefix}:clock:phase`,
],
arguments: [
String(payload.sourceRevision),
String(payload.targetRevision),
String(payload.deadlineGeneration),
outbox.checksum,
tournament?.expected ?? '__NONE__',
tournament?.next ?? '__NONE__',
String(timers.length),
...timers.flatMap(({ score, id }) => [String(score), id]),
],
});
const applied = Number(result);
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)}`);
await options.db.$transaction(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await transaction.$queryRaw<ClaimedOutboxRow[]>(GamePrisma.sql`
SELECT id FROM world_state WHERE id = ${outbox.worldStateId} FOR UPDATE
`);
const finalized = await transaction.worldState.updateMany({
where: {
id: outbox.worldStateId,
clockPhase: 'RECONCILING',
clockRevision: outbox.targetRevision,
deadlineGeneration: BigInt(payload.deadlineGeneration),
},
data: { clockPhase: 'RUNNING' },
});
if (finalized.count !== 1) {
throw new Error('Clock projection final RUNNING transition fence failed.');
}
const appliedAt = await readDbWall(transaction);
await transaction.clockProjectionOutbox.update({
where: { id: outbox.id },
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
});
if (outbox.suspensionId) {
await transaction.clockSuspension.update({
where: { id: outbox.suspensionId },
data: { status: 'APPLIED' },
});
}
});
return applied === 2 ? 'RECOVERED' : 'APPLIED';
} catch (error) {
await recordFailure(options.db, outbox.id, error);
throw error;
}
};
export const loadClockReconciliationReadiness = async (db: GamePrismaClient) => {
const world = await db.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
});
const incompleteOutboxCount = await db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } });
if (!world) {
return { ready: false, phase: null, revision: null, deadlineGeneration: null, incompleteOutboxCount };
}
const phase = parseGameClockPhase(world.clockPhase);
return {
ready: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
phase,
revision: safeInteger(world.clockRevision, 'clock revision'),
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
incompleteOutboxCount,
};
};
@@ -0,0 +1,1044 @@
import { createHash } from 'node:crypto';
import {
GAME_TICKS_PER_TURN,
MAX_SAFE_GAME_TICK,
GameClock,
buildClockAlignmentPlan,
parseClockAlignmentPolicy,
parseGameClockPhase,
type ClockAlignmentPolicy,
} from '@sammo-ts/common';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
export type ClockSuspensionSource = 'MAINTENANCE' | 'OPEN_DELAY' | 'UNIFICATION_WAIT' | 'RECOVERY';
export type ClockOperationAuthority =
| { kind: 'DAEMON'; profileName: string; ownerId: string; fencingEpoch: bigint }
| { kind: 'OFFLINE'; profileName: string; reason: string };
export interface ClockSuspensionResult {
suspensionId: string;
phase: 'SUSPENDED';
sourceRevision: number;
targetRevision: number;
cutTick: number;
cutWallAt: Date;
}
export interface ClockReconciliationResult {
suspensionId: string;
phase: 'RECONCILING';
sourceRevision: number;
targetRevision: number;
deadlineGeneration: number;
gapTicks: number;
catchUpTicks: number;
shiftTicks: number;
alignedTick: number;
resumeWallAt: Date;
}
interface DbWallRow {
wallNow: Date;
}
interface LeaseFenceRow {
ownerId: string;
fencingEpoch: bigint;
valid: boolean;
}
interface IdRow {
id: number;
}
interface TextIdRow {
id: string;
}
interface ParticipantSnapshot {
key: string;
policy: 'SHIFT' | 'KEEP' | 'REBUILD';
checksum: string;
count: number;
}
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const safeNumber = (value: bigint, label: string): number => {
const result = Number(value);
if (!Number.isSafeInteger(result)) {
throw new Error(`${label} is outside the JavaScript safe integer range: ${value}`);
}
return result;
};
const canonicalize = (value: unknown): unknown => {
if (typeof value === 'bigint') return value.toString();
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)])
);
}
return value;
};
const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value));
const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex');
const aggregateChecksum = (participants: readonly ParticipantSnapshot[]): string =>
checksum(participants.map(({ key, policy, checksum: value, count }) => ({ key, policy, checksum: value, count })));
const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
const rows = await db.$queryRaw<DbWallRow[]>(GamePrisma.sql`
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow"
`);
const wallNow = rows[0]?.wallNow;
if (!wallNow || Number.isNaN(wallNow.getTime())) {
throw new Error('Failed to read the PostgreSQL wall clock.');
}
return wallNow;
};
export const readClockDatabaseWall = readDbWall;
const isRetryableSerializableClockError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false;
const value = error as { code?: unknown; message?: unknown; meta?: unknown };
const meta =
value.meta && typeof value.meta === 'object'
? (value.meta as { code?: unknown; message?: unknown })
: undefined;
const code = typeof value.code === 'string' ? value.code : '';
const databaseCode = typeof meta?.code === 'string' ? meta.code : '';
const message = [value.message, meta?.message]
.filter((entry): entry is string => typeof entry === 'string')
.join(' ');
return (
code === 'P2034' ||
code === '40001' ||
databaseCode === '40001' ||
message.includes('40001') ||
message.includes('could not serialize access')
);
};
const runSerializableClockOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
const maxAttempts = 3;
for (let attempt = 1; ; attempt += 1) {
try {
return await operation();
} catch (error) {
if (attempt >= maxAttempts || !isRetryableSerializableClockError(error)) {
throw error;
}
await new Promise<void>((resolve) => setTimeout(resolve, attempt * 10));
}
}
};
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",
lease_until > (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS valid
FROM turn_daemon_lease
WHERE profile = ${authority.profileName}
FOR UPDATE
`);
const lease = rows[0];
if (authority.kind === 'OFFLINE') {
if (!authority.reason.trim()) {
throw new Error('Offline clock operations require an audit reason.');
}
if (lease?.valid) {
throw new Error(`Clock operation requires the ${authority.profileName} daemon lease to be offline.`);
}
return;
}
if (!lease?.valid || lease.ownerId !== authority.ownerId || lease.fencingEpoch !== authority.fencingEpoch) {
throw new Error(`Stale turn-daemon fencing authority for profile ${authority.profileName}.`);
}
};
const lockWorld = async (db: GamePrisma.TransactionClient): Promise<number> => {
const rows = await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM world_state ORDER BY id LIMIT 2 FOR UPDATE
`);
if (rows.length !== 1) {
throw new Error(`Clock reconciliation requires exactly one world_state row; found ${rows.length}.`);
}
return rows[0]!.id;
};
const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigint): Promise<void> => {
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM general ORDER BY id FOR UPDATE`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM auction
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
ORDER BY id FOR UPDATE
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT bid.id
FROM auction_bid AS bid
JOIN auction ON auction.id = bid.auction_id
WHERE auction.status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
ORDER BY bid.id FOR UPDATE OF bid
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT message_id AS id FROM message_action
WHERE status = 'PENDING'
ORDER BY message_id FOR UPDATE
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM inheritance_ledger ORDER BY id FOR UPDATE`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM select_pool WHERE general_id IS NULL ORDER BY id FOR UPDATE
`);
await db.$queryRaw<TextIdRow[]>(GamePrisma.sql`
SELECT owner_user_id AS id FROM select_npc_token ORDER BY owner_user_id FOR UPDATE
`);
};
const readParticipantSnapshots = async (
db: GamePrisma.TransactionClient,
worldStateId: number,
cutTick: bigint
): Promise<ParticipantSnapshot[]> => {
const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] =
await Promise.all([
db.worldState.findUniqueOrThrow({
where: { id: worldStateId },
select: {
clockTick: true,
clockRevision: true,
deadlineGeneration: true,
lastTurnTick: true,
meta: true,
},
}),
db.general.findMany({
orderBy: { id: 'asc' },
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
}),
db.auction.findMany({
where: { status: { in: ['OPEN', 'FINALIZING'] } },
orderBy: { id: 'asc' },
select: { id: true, status: true, openTick: true, closeTick: true },
}),
db.auctionBid.findMany({
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
orderBy: { id: 'asc' },
select: { id: true, occurredGameTick: true },
}),
db.messageAction.findMany({
where: { status: 'PENDING' },
orderBy: { messageId: 'asc' },
select: {
messageId: true,
createdGameTick: true,
expiresGameTick: true,
clockRevision: true,
deadlineGeneration: true,
},
}),
db.inheritanceLedger.findMany({
orderBy: { id: 'asc' },
select: { id: true, appliedClockRevision: true, appliedDeadlineGeneration: true },
}),
db.votePoll.findMany({
where: { closedAt: null },
orderBy: { id: 'asc' },
select: { id: true, startTick: true, endTick: true },
}),
db.selectPoolEntry.findMany({
where: { generalId: null },
orderBy: { id: 'asc' },
select: { id: true, reservedUntilTick: true },
}),
db.npcSelectionToken.findMany({
orderBy: { ownerUserId: 'asc' },
select: { ownerUserId: true, validUntilTick: true, pickMoreFromTick: true },
}),
db.inputEvent.findMany({
where: { status: { in: ['PENDING', 'PROCESSING'] } },
orderBy: { sequence: 'asc' },
select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true },
}),
]);
const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({
key,
policy,
checksum: checksum(rows),
count: rows.length,
});
const meta = world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta) ? world.meta : {};
return [
snapshot('world-clock', 'REBUILD', [
{
clockTick: world.clockTick,
clockRevision: world.clockRevision,
deadlineGeneration: world.deadlineGeneration,
},
]),
snapshot('turn-cursor', 'SHIFT', [{ lastTurnTick: world.lastTurnTick }]),
snapshot(
'general-next-turn',
'SHIFT',
generals.map(({ id, turnTick }) => ({ id, turnTick }))
),
snapshot(
'general-recent-war-occurrence',
'KEEP',
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick }))
),
snapshot(
'selection-reselection-deadline',
'SHIFT',
generals.flatMap(({ id, meta: generalMeta }) => {
const raw =
generalMeta && typeof generalMeta === 'object' && !Array.isArray(generalMeta)
? Reflect.get(generalMeta, 'next_change_tick')
: null;
const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
})
),
snapshot(
'auction-open-occurrence',
'KEEP',
auctions.map(({ id, openTick }) => ({ id, openTick }))
),
snapshot(
'auction-deadline',
'SHIFT',
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
),
snapshot('auction-bid-occurrence', 'KEEP', auctionBids),
snapshot(
'auction-finalizing-recovery',
'REBUILD',
auctions.map(({ id, status }) => ({ id, status }))
),
snapshot(
'message-action-occurrence',
'KEEP',
messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick }))
),
snapshot(
'message-action-expiry',
'SHIFT',
messages
.filter(({ expiresGameTick }) => expiresGameTick !== null && expiresGameTick >= cutTick)
.map(({ messageId, expiresGameTick }) => ({ messageId, expiresGameTick }))
),
snapshot(
'message-action-clock-coordinate',
'REBUILD',
messages.map(({ messageId, clockRevision, deadlineGeneration }) => ({
messageId,
clockRevision,
deadlineGeneration,
}))
),
snapshot('inheritance-effect-coordinate', 'KEEP', inheritanceEffects),
snapshot(
'vote-start-occurrence',
'KEEP',
votes.map(({ id, startTick }) => ({ id, startTick }))
),
snapshot(
'vote-end-deadline',
'SHIFT',
votes.map(({ id, endTick }) => ({ id, endTick }))
),
snapshot('select-pool-reservation', 'SHIFT', pool),
snapshot('npc-selection-window', 'SHIFT', npcTokens),
snapshot('daemon-command-coordinate', 'KEEP', commands),
snapshot('movable-json-rule-anchors', 'SHIFT', [
{
lastTurnTime: Reflect.get(meta, 'lastTurnTime'),
turntime: Reflect.get(meta, 'turntime'),
starttime: Reflect.get(meta, 'starttime'),
tnmt_time: Reflect.get(meta, 'tnmt_time'),
},
]),
];
};
const persistInitialParticipants = async (
db: GamePrisma.TransactionClient,
suspensionId: string,
participants: readonly ParticipantSnapshot[]
): Promise<void> => {
for (const participant of participants) {
await db.clockReconciliationParticipant.create({
data: {
suspensionId,
participantKey: participant.key,
policy: participant.policy,
beforeChecksum: participant.checksum,
afterChecksum: participant.checksum,
affectedCount: 0,
},
});
}
};
/**
* 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);
if (Number.isNaN(parsed.getTime())) return value;
return new Date(parsed.getTime() + deltaMilliseconds).toISOString();
};
const shiftedMeta = (value: GamePrisma.JsonValue, deltaMilliseconds: number): GamePrisma.InputJsonValue => {
const meta = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
for (const key of ['lastTurnTime', 'turntime', 'starttime', 'tnmt_time'] as const) {
if (Object.hasOwn(meta, key)) {
Reflect.set(meta, key, shiftMetaDate(Reflect.get(meta, key), deltaMilliseconds));
}
}
return asJson(meta);
};
const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTicks: number): void => {
if (!Number.isSafeInteger(shiftTicks) || shiftTicks < 0) {
throw new Error(`Invalid reconciliation shift: ${shiftTicks}`);
}
// Checksums retain stringified values for audit; actual row ranges are
// checked by PostgreSQL BIGINT and the world aligned tick is checked by the
// shared GameClock plan. The sentinel expiry is deliberately never shifted.
if (participants.some((participant) => !participant.checksum)) {
throw new Error('Participant snapshot is incomplete.');
}
};
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
const shift = BigInt(shiftTicks);
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
FROM general
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
`),
db.auction.aggregate({
_max: { closeTick: true },
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
}),
db.messageAction.aggregate({
_max: { expiresGameTick: true },
where: { status: 'PENDING', expiresGameTick: { not: null } },
}),
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
db.selectPoolEntry.aggregate({
_max: { reservedUntilTick: true },
where: { generalId: null, reservedUntilTick: { not: null } },
}),
db.npcSelectionToken.aggregate({ _max: { validUntilTick: true }, where: { validUntilTick: { not: null } } }),
db.npcSelectionToken.aggregate({
_max: { pickMoreFromTick: true },
where: { pickMoreFromTick: { not: null } },
}),
]);
const values: Array<[string, bigint | null]> = [
['general.turn_tick', general._max.turnTick],
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
['auction.close_tick', auction._max.closeTick],
['message_action.expires_game_tick', message._max.expiresGameTick],
['vote_poll.end_tick', vote._max.endTick],
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
['select_npc_token.pick_more_from_tick', npcMore._max.pickMoreFromTick],
];
for (const [label, value] of values) {
if (value !== null && value > maximum) {
throw new Error(`${label} would exceed the safe game tick range after reconciliation.`);
}
}
};
const applyParticipantShift = async (
db: GamePrisma.TransactionClient,
worldStateId: number,
cutTick: bigint,
alignedTick: bigint,
targetRevision: bigint,
targetGeneration: bigint,
shiftTicks: bigint,
projectionDeltaMilliseconds: number,
resumeWallAt: Date
): Promise<Map<string, number>> => {
const affected = new Map<string, number>();
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId }, select: { meta: true } });
const cursor = await db.worldState.updateMany({
where: { id: worldStateId, lastTurnTick: { not: null } },
data: { lastTurnTick: { increment: shiftTicks } },
});
affected.set('turn-cursor', cursor.count);
affected.set(
'general-next-turn',
await db.$executeRaw(GamePrisma.sql`
UPDATE general
SET turn_tick = turn_tick + ${shiftTicks},
turn_time = turn_time + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
WHERE turn_tick IS NOT NULL
`)
);
affected.set(
'selection-reselection-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE general
SET meta = jsonb_set(
jsonb_set(
jsonb_set(
meta,
'{next_change_tick}',
to_jsonb((meta->>'next_change_tick')::bigint + ${shiftTicks}),
true
),
'{next_change}',
to_jsonb(((meta->>'next_change')::timestamp
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
true
),
'{nextChangeAt}',
to_jsonb(((meta->>'nextChangeAt')::timestamp
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
true
)
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
`)
);
affected.set(
'auction-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE auction
SET close_tick = close_tick + ${shiftTicks},
close_at = close_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
AND close_tick IS NOT NULL
`)
);
affected.set(
'message-action-clock-coordinate',
(
await db.messageAction.updateMany({
where: { status: 'PENDING' },
data: {
clockRevision: targetRevision,
deadlineGeneration: targetGeneration,
},
})
).count
);
affected.set(
'message-action-expiry',
await db.$executeRaw(GamePrisma.sql`
WITH shifted AS (
UPDATE message_action
SET expires_game_tick = expires_game_tick + ${shiftTicks},
clock_revision = ${targetRevision},
deadline_generation = ${targetGeneration},
updated_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE status = 'PENDING'
AND expires_game_tick IS NOT NULL
AND expires_game_tick >= ${cutTick}
RETURNING message_id, expires_game_tick
)
UPDATE message AS envelope
SET valid_until_tick = shifted.expires_game_tick,
valid_until = envelope.valid_until
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
FROM shifted
WHERE envelope.id = shifted.message_id
`)
);
affected.set(
'vote-end-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE vote_poll
SET end_tick = end_tick + ${shiftTicks},
end_at = end_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
WHERE closed_at IS NULL AND end_tick IS NOT NULL
`)
);
affected.set(
'select-pool-reservation',
await db.$executeRaw(GamePrisma.sql`
UPDATE select_pool
SET reserved_until_tick = reserved_until_tick + ${shiftTicks},
reserved_until = reserved_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
WHERE general_id IS NULL AND reserved_until_tick IS NOT NULL
`)
);
affected.set(
'npc-selection-window',
await db.$executeRaw(GamePrisma.sql`
UPDATE select_npc_token
SET valid_until_tick = CASE
WHEN valid_until_tick IS NULL THEN NULL ELSE valid_until_tick + ${shiftTicks} END,
valid_until = CASE
WHEN valid_until_tick IS NULL THEN valid_until
ELSE valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END,
pick_more_from_tick = CASE
WHEN pick_more_from_tick IS NULL THEN NULL ELSE pick_more_from_tick + ${shiftTicks} END,
pick_more_from = CASE
WHEN pick_more_from_tick IS NULL THEN pick_more_from
ELSE pick_more_from + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END
WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL
`)
);
await db.worldState.update({
where: { id: worldStateId },
data: {
clockTick: alignedTick,
clockWallAnchor: resumeWallAt,
clockPhase: 'RECONCILING',
clockRevision: targetRevision,
deadlineGeneration: targetGeneration,
meta: shiftedMeta(world.meta, projectionDeltaMilliseconds),
},
});
affected.set('world-clock', 1);
affected.set('movable-json-rule-anchors', 1);
affected.set('auction-finalizing-recovery', 0);
return affected;
};
export const startClockSuspension = async (options: {
db: GamePrismaClient;
suspensionId: string;
source: ClockSuspensionSource;
authority: ClockOperationAuthority;
policy?: ClockAlignmentPolicy;
catchUpTicks?: number;
}): Promise<ClockSuspensionResult> => {
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.');
}
return runSerializableClockOperation(() =>
options.db.$transaction(
async (db) => {
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 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.status !== 'SUSPENDED') {
throw new Error(
`Clock suspension ${options.suspensionId} already advanced to ${existing.status}.`
);
}
return {
suspensionId: existing.id,
phase: 'SUSPENDED' as const,
sourceRevision: safeNumber(existing.sourceRevision, 'source revision'),
targetRevision: safeNumber(existing.targetRevision, 'target revision'),
cutTick: safeNumber(existing.cutTick, 'cut tick'),
cutWallAt: existing.cutWallAt,
};
}
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
const phase = parseGameClockPhase(world.clockPhase);
if (phase !== 'RUNNING') {
throw new Error(`Clock suspension can start only from RUNNING; current phase is ${phase}.`);
}
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
throw new Error('Clock suspension requires a fully initialized logical game clock.');
}
const cutWallAt = await readDbWall(db);
const storedTick = safeNumber(world.clockTick, 'world clock tick');
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
const clock = new GameClock({
baseTime: world.clockBaseTime,
tick: storedTick,
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: world.clockWallAnchor,
turnSeconds: world.tickSeconds,
phase,
revision: sourceRevision,
});
const cutTick = clock.nowTick(cutWallAt);
await lockParticipants(db, BigInt(cutTick));
await db.worldState.update({
where: { id: worldStateId },
data: { clockPhase: 'SUSPENDED', clockTick: BigInt(cutTick), clockWallAnchor: cutWallAt },
});
const participants = await readParticipantSnapshots(db, worldStateId, BigInt(cutTick));
await db.clockSuspension.create({
data: {
id: options.suspensionId,
worldStateId,
source: options.source,
policy,
status: 'SUSPENDED',
sourceRevision: BigInt(sourceRevision),
targetRevision: BigInt(sourceRevision + 1),
cutTick: BigInt(cutTick),
cutWallAt,
rateTicksPerSecond: GAME_TICKS_PER_TURN / world.tickSeconds,
catchUpTicks: BigInt(catchUpTicks),
participantChecksumBefore: aggregateChecksum(participants),
detail: asJson({
authority: options.authority.kind,
profileName: options.authority.profileName,
}),
},
});
await persistInitialParticipants(db, options.suspensionId, participants);
return {
suspensionId: options.suspensionId,
phase: 'SUSPENDED',
sourceRevision,
targetRevision: sourceRevision + 1,
cutTick,
cutWallAt,
};
},
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
)
);
};
/**
* 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;
authority: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date;
}): Promise<ClockReconciliationResult> =>
runSerializableClockOperation(() =>
options.db.$transaction(
async (db) => {
await verifyAuthority(db, options.authority);
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
return reconcileClockSuspensionInTransaction({
db,
suspensionId: options.suspensionId,
profileName: options.authority.profileName,
authority: options.authority,
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
});
},
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
)
);
+20 -3
View File
@@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({
type: z.literal('auctionFinalize'),
auctionId: zFiniteNumber,
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
expectedCloseTick: zSafeInteger.optional(),
expectedCloseTick: zSafeInteger,
});
const zAuctionOpen = z.object({
@@ -60,7 +60,6 @@ const zAuctionBid = z.object({
auctionId: zFiniteNumber,
generalId: zFiniteNumber,
amount: zFiniteNumber,
acceptedGameTick: zSafeInteger.optional(),
tryExtendCloseDate: z.boolean().optional(),
});
@@ -132,6 +131,15 @@ const zMessageRespond = z.object({
response: z.boolean(),
});
const zSyncDiplomaticResponse = z.object({
type: z.literal('syncDiplomaticResponse'),
userId: z.string().min(1),
generalId: z.number().int().positive(),
messageId: z.number().int().positive(),
nationIds: z.array(z.number().int().positive()).max(4),
cityIds: z.array(z.number().int().positive()).max(256),
});
const zVacation = z.object({
type: z.literal('vacation'),
userId: z.string().min(1),
@@ -427,7 +435,7 @@ const zSelectPoolReserve = z
requestId: z.string().optional(),
userId: z.string().min(1),
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp),
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
})
.strict();
@@ -610,6 +618,14 @@ const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope)
return { ...command, requestId: envelope.requestId };
};
const normalizeSyncDiplomaticResponse: CommandNormalizer<'syncDiplomaticResponse'> = (envelope) => {
const command = parseWith(zSyncDiplomaticResponse, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => {
const command = parseWith(zVacation, envelope.command);
if (!command) {
@@ -860,6 +876,7 @@ const normalizers: CommandNormalizerMap = {
buildNationCandidate: normalizeBuildNationCandidate,
instantRetreat: normalizeInstantRetreat,
messageRespond: normalizeMessageRespond,
syncDiplomaticResponse: normalizeSyncDiplomaticResponse,
vacation: normalizeVacation,
setMySetting: normalizeSetMySetting,
dropItem: normalizeDropItem,
+259 -46
View File
@@ -1,11 +1,13 @@
import {
acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
writeReadModelChangeJournal,
enqueuePrivateMessageWebPush,
enqueueWebPushOutboxEvents,
persistMessageEnvelope,
type InputJsonValue,
type ReadModelJournalWriteResult,
type TurnEngineCityUpdateInput,
@@ -55,12 +57,22 @@ 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';
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise<void>;
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
synchronizeClockAuthority(): Promise<boolean>;
}
export interface CommittedReadModelChangeReceipt {
@@ -122,6 +134,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 +151,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 +1140,19 @@ 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 +1185,129 @@ 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;
clock_initialized: boolean;
opening_reached: boolean;
}>
>(GamePrisma.sql`
SELECT clock_phase,
clock_revision,
deadline_generation,
clock_base_time IS NOT NULL
AND clock_tick IS NOT NULL
AND clock_wall_anchor IS NOT NULL
AND last_turn_tick IS NOT NULL AS clock_initialized,
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);
// Match worldLoader's dual-read boundary: a legacy row is not an
// authoritative RUNNING clock merely because the newly-added phase
// column has its database default. Its first fenced flush installs
// the complete MANUAL snapshot atomically.
const durablePhase = durableClock.clock_initialized ? durableClock.clock_phase : 'MANUAL';
const stateMeta = asRecord(state.meta);
const unificationSuspensionId =
typeof stateMeta.unificationClockSuspensionId === 'string'
? stateMeta.unificationClockSuspensionId
: null;
const openingPhaseTransition =
durableClock.clock_initialized &&
durablePhase === 'PREOPEN' &&
expectedPhase === 'RUNNING' &&
durableClock.opening_reached;
const unificationSuspensionTransition =
durableClock.clock_initialized &&
durablePhase === 'RUNNING' &&
expectedPhase === 'SUSPENDED' &&
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) === 2 &&
Boolean(unificationSuspensionId);
const completionPhaseTransition =
durableClock.clock_initialized &&
durablePhase === 'RUNNING' &&
expectedPhase === 'COMPLETED' &&
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) >= 2;
if (
(!openingPhaseTransition &&
!unificationSuspensionTransition &&
!completionPhaseTransition &&
durablePhase !== expectedPhase) ||
durableClock.clock_revision !== expectedRevision ||
durableClock.deadline_generation !== expectedGeneration
) {
throw new Error(
`Game clock fence changed before flush: expected ${expectedPhase}@${expectedRevision}/${expectedGeneration}, ` +
`found ${durablePhase}@${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<{
status: string;
processing_clock_revision: bigint | null;
processing_deadline_generation: bigint | null;
}>
>(GamePrisma.sql`
SELECT status,
processing_clock_revision,
processing_deadline_generation
FROM input_event
WHERE request_id = ${commandCompletion.requestId}
AND target = 'ENGINE'::"InputEventTarget"
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' ||
(!unificationRevisionTransition &&
(event.processing_clock_revision !== expectedRevision ||
event.processing_deadline_generation !== expectedGeneration))
) {
throw new Error(
`Input event processing clock fence changed before commit: ${commandCompletion.requestId}.`
);
}
}
let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey =
@@ -1261,6 +1412,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) {
@@ -1323,20 +1503,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(
@@ -1699,37 +1865,18 @@ export const createDatabaseTurnHooks = async (
await sendMessage(
{
insertMessage: async (draft: MessageRecordDraft) => {
const toTickOrNull = (date: Date): bigint | null => {
try {
return BigInt(world.dateToGameTick(date));
} catch {
// Legacy messages may use year 9999 as an
// effectively-unbounded expiry, beyond the
// safe JavaScript tick range.
return null;
}
};
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (
mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message
)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${toTickOrNull(draft.time)},
${draft.validUntil},
${toTickOrNull(draft.validUntil)},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) {
throw new Error('Failed to persist turn message.');
}
const clock = world.getGameClockState();
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
const expiresGameTick =
typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000
? null
: BigInt(world.dateToGameTick(draft.validUntil));
const id = await persistMessageEnvelope(prisma, draft, {
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
clockRevision: BigInt(clock.revision),
deadlineGeneration: BigInt(clock.deadlineGeneration),
expiresGameTick,
});
await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox);
return id;
@@ -1742,6 +1889,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 },
@@ -1827,6 +2009,11 @@ 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);
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
const leaseToken = options?.turnDaemonLease?.getToken();
const directLogFloor =
(
await transaction.logEntry.findFirst({
@@ -1834,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);
@@ -1850,6 +2049,20 @@ 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';
},
synchronizeClockAuthority: () =>
prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
return synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
}, transactionOptions),
close: () => connector.disconnect(),
};
};
+23 -18
View File
@@ -1,4 +1,4 @@
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
import { createGatewayPostgresConnector, GatewayPrisma } from '@sammo-ts/infra';
import { isRecord } from '@sammo-ts/common';
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
@@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async (
continue;
}
const terminal = result.status !== 'PARTIAL';
const updated = await prisma.gatewayRuntimeAction.updateMany({
where: {
id: action.id,
status: { in: ['REQUESTED', 'PARTIAL'] },
},
data: {
status: result.status,
detail: result.detail ?? null,
handler: 'turn-daemon',
handledAt: terminal ? new Date() : null,
attempts: { increment: 1 },
nextAttemptAt: terminal
? null
: new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))),
},
});
if (terminal && updated.count > 0) {
const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6));
const updated = await prisma.$queryRaw<Array<{ id: string }>>(GatewayPrisma.sql`
UPDATE gateway_runtime_action
SET status = ${result.status}::"GatewayRuntimeActionStatus",
detail = ${result.detail ?? null},
handler = 'turn-daemon',
handled_at = CASE
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
ELSE NULL
END,
attempts = attempts + 1,
next_attempt_at = CASE
WHEN ${terminal} THEN NULL
ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${retryDelayMs} * INTERVAL '1 millisecond'
END,
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${action.id}
AND status IN ('REQUESTED'::"GatewayRuntimeActionStatus", 'PARTIAL'::"GatewayRuntimeActionStatus")
RETURNING id
`);
if (terminal && updated.length > 0) {
await options.onActionApplied?.(actionRecord, result);
}
}
@@ -1,3 +1,5 @@
import { performance } from 'node:perf_hooks';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
@@ -15,6 +17,7 @@ export interface GatewayProfileGate {
}
const DEFAULT_CACHE_MS = 2000;
const PROFILE_STATUSES_MARKABLE_AS_PAUSED = ['PREOPEN', 'RUNNING', 'PAUSED'] as const;
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
const connector = createGatewayPostgresConnector({
@@ -42,7 +45,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return {
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
async shouldPause(): Promise<boolean> {
const now = Date.now();
const now = performance.now();
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
return cachedPause;
}
@@ -53,8 +56,11 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
async markPaused(error?: unknown): Promise<void> {
const message = error instanceof Error ? error.message : error ? String(error) : null;
try {
await prisma.gatewayProfile.update({
where: { profileName: options.profileName },
await prisma.gatewayProfile.updateMany({
where: {
profileName: options.profileName,
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
},
data: {
status: 'PAUSED',
lastError: message,
+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);
}
@@ -1,3 +1,5 @@
import { performance } from 'node:perf_hooks';
import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { InMemoryTurnWorld, TurnCalendarContext } from './inMemoryWorld.js';
@@ -50,16 +52,16 @@ export class InMemoryTurnProcessor implements TurnProcessor {
}
async run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise<TurnRunResult> {
const startMs = Date.now();
const startMs = performance.now();
const deadlineMs = startMs + Math.max(0, budget.budgetMs);
const isBudgetExpired = () => Date.now() >= deadlineMs;
const isBudgetExpired = () => performance.now() >= deadlineMs;
if (isWorldUnited(this.world)) {
return {
lastTurnTime: this.world.getState().lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: Math.max(0, Date.now() - startMs),
durationMs: Math.max(0, performance.now() - startMs),
partial: false,
checkpoint,
};
@@ -171,7 +173,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
lastTurnTime,
processedGenerals,
processedTurns,
durationMs: Math.max(0, Date.now() - startMs),
durationMs: Math.max(0, performance.now() - startMs),
partial,
checkpoint: nextCheckpoint,
};
+231 -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,23 @@ export interface InMemoryGameClockState {
mode: GameClockMode;
wallAnchor: Date;
lastTurnTick: number;
phase: GameClockPhase;
revision: number;
deadlineGeneration: number;
}
export interface DurableGameClockSnapshot extends InMemoryGameClockState {
lastTurnTick: number;
}
export interface DurableClockReconciliationAlignment {
suspensionId: string;
sourceRevision: number;
targetRevision: number;
deadlineGeneration: number;
alignedTick: number;
shiftTicks: number;
resumeWallAt: Date;
}
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
@@ -525,6 +549,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 +559,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 +570,8 @@ export class InMemoryTurnWorld {
mode,
wallAnchor,
turnSeconds: state.tickSeconds,
phase,
revision,
});
const lastTurnTime = gameClock.tickToDate(lastTurnTick);
this.state = {
@@ -550,6 +581,9 @@ export class InMemoryTurnWorld {
clockMode: mode,
clockWallAnchor: wallAnchor,
lastTurnTick,
clockPhase: phase,
clockRevision: revision,
deadlineGeneration,
lastTurnTime,
meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() },
};
@@ -621,6 +655,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 +685,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 +695,193 @@ 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;
}
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' };
}
private applyClockReconciliationAlignment(input: DurableClockReconciliationAlignment): void {
if (
!Number.isSafeInteger(input.alignedTick) ||
!Number.isSafeInteger(input.shiftTicks) ||
input.shiftTicks < 0 ||
!Number.isSafeInteger(input.sourceRevision) ||
!Number.isSafeInteger(input.targetRevision) ||
input.targetRevision !== input.sourceRevision + 1 ||
!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(),
};
}
}
applyClockReconciliation(input: Omit<DurableClockReconciliationAlignment, 'sourceRevision'>): 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.');
}
this.applyClockReconciliationAlignment({
...input,
sourceRevision: this.state.clockRevision ?? 1,
});
}
applyDurableClockReconciliation(input: DurableClockReconciliationAlignment): void {
const clock = this.getGameClockState();
if (clock.revision === input.targetRevision && clock.phase === 'RECONCILING') {
return;
}
if (clock.revision !== input.sourceRevision) {
throw new Error(
`Durable clock reconciliation source mismatch: memory ${clock.revision}, ledger ${input.sourceRevision}.`
);
}
if (clock.phase !== 'RUNNING' && clock.phase !== 'SUSPENDED') {
throw new Error(`Durable clock reconciliation cannot apply from in-memory phase ${clock.phase}.`);
}
this.applyClockReconciliationAlignment(input);
}
synchronizeDurableClockSnapshot(input: DurableGameClockSnapshot): void {
const current = this.getGameClockState();
if (current.revision !== input.revision || current.deadlineGeneration !== input.deadlineGeneration) {
throw new Error(
`Durable clock snapshot generation mismatch: memory ${current.revision}/${current.deadlineGeneration}, ` +
`database ${input.revision}/${input.deadlineGeneration}.`
);
}
if ((this.state.lastTurnTick ?? 0) !== input.lastTurnTick) {
throw new Error(
`Durable clock snapshot turn cursor mismatch: memory ${this.state.lastTurnTick ?? 0}, database ${input.lastTurnTick}.`
);
}
const currentBaseTime = this.state.clockBaseTime ?? this.state.lastTurnTime;
if (currentBaseTime.getTime() !== input.baseTime.getTime()) {
throw new Error(
`Durable clock snapshot base mismatch: memory ${currentBaseTime.toISOString()}, ` +
`database ${input.baseTime.toISOString()}.`
);
}
const validTransition =
current.phase === input.phase ||
(current.phase === 'RUNNING' && input.phase === 'SUSPENDED') ||
(current.phase === 'RECONCILING' && input.phase === 'RUNNING');
if (!validTransition) {
throw new Error(`Durable clock snapshot phase mismatch: memory ${current.phase}, database ${input.phase}.`);
}
this.state = {
...this.state,
clockBaseTime: new Date(input.baseTime.getTime()),
clockTick: input.tick,
clockMode: input.mode,
clockWallAnchor: new Date(input.wallAnchor.getTime()),
clockPhase: input.phase,
clockRevision: input.revision,
deadlineGeneration: input.deadlineGeneration,
};
}
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
// 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 +897,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 +918,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 +1191,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 +1694,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 +1831,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 +2014,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;
@@ -310,7 +310,7 @@ export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
return '알수없음';
};
export const executeInheritanceAction = async (options: {
const executeInheritanceActionMutation = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
command: InheritanceActionCommand;
@@ -668,3 +668,59 @@ export const executeInheritanceAction = async (options: {
});
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost };
};
/**
* Persists the WALL_TIME inheritance receipt in the same transaction as the
* point debit, game mutation, and input-event completion. The input_event row
* is the durable retry/failure record and owns the authoritative GAME clock
* coordinate; an immediate effect does not invent a separate applied tick.
*/
export const executeInheritanceAction = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
command: InheritanceActionCommand;
gameNow: Date;
}): Promise<InheritanceActionResult> => {
const result = await executeInheritanceActionMutation(options);
if (!result.ok || !options.command.requestId) return result;
const event = await options.db.inputEvent.findUnique({
where: { requestId: options.command.requestId },
select: {
actorUserId: true,
target: true,
eventType: true,
createdAt: true,
processingClockRevision: true,
processingDeadlineGeneration: true,
},
});
if (
!event ||
event.actorUserId !== options.command.userId ||
event.target !== 'ENGINE' ||
event.eventType !== 'inheritanceAction' ||
event.processingClockRevision === null ||
event.processingDeadlineGeneration === null
) {
throw new Error('Inheritance ledger requires the authoritative ENGINE input-event clock fence.');
}
const previousPoint = await lockPreviousPoint(options.db, options.command.userId);
const cost = previousPoint - result.remainPoint;
if (!Number.isFinite(cost) || cost < 0) {
throw new Error(`Inheritance ledger calculated an invalid cost: ${cost}.`);
}
await options.db.inheritanceLedger.create({
data: {
requestId: options.command.requestId,
userId: options.command.userId,
action: result.action,
cost,
status: 'APPLIED',
requestedAtWall: event.createdAt,
appliedClockRevision: event.processingClockRevision,
appliedDeadlineGeneration: event.processingDeadlineGeneration,
},
});
return result;
};
@@ -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
@@ -548,6 +549,7 @@ export const createInvaderEndingHandler = (options: {
isUnited: 3,
refreshLimit: readNumber(meta.refreshLimit) * 100,
});
world.completeGameClock();
world.removeEvent(environment.currentEventID);
};
};
@@ -1,6 +1,6 @@
import { randomInt } from 'node:crypto';
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import {
acquireGameSchemaAdvisoryXactLock,
GamePrisma,
@@ -84,7 +84,9 @@ export interface NpcPossessionSelectionObserver {
interface NpcSelectionTokenRow {
ownerUserId: string;
validUntil: Date;
validUntilTick: bigint | null;
pickMoreFrom: Date;
pickMoreFromTick: bigint | null;
pickResult: unknown;
nonce: number;
}
@@ -105,8 +107,8 @@ const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTi
export const buildNpcSelectionTokenSeed = (
hiddenSeed: string | number,
ownerIdentity: string | number,
acceptedGameTick: number
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick);
createdGameTick: number
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, createdGameTick);
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
const meta = asRecord(worldState.meta);
@@ -159,15 +161,22 @@ const parsePickResult = (value: unknown): Record<string, NpcPossessionCandidate>
};
const toReservation = (
token: Pick<NpcSelectionTokenRow, 'validUntil' | 'pickMoreFrom' | 'pickResult' | 'nonce'>,
now: Date
token: Pick<
NpcSelectionTokenRow,
'validUntil' | 'validUntilTick' | 'pickMoreFrom' | 'pickMoreFromTick' | 'pickResult' | 'nonce'
>,
currentGameTick: number,
ticksPerSecond: number
): NpcPossessionReservation => {
if (token.validUntilTick === null || token.pickMoreFromTick === null) {
return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보의 GAME_TIME 기한이 없습니다.');
}
const pickResult = parsePickResult(token.pickResult);
return {
tokenNonce: token.nonce,
validUntil: token.validUntil.toISOString(),
pickMoreFrom: token.pickMoreFrom.toISOString(),
pickMoreSeconds: Math.max(0, Math.ceil((token.pickMoreFrom.getTime() - now.getTime()) / 1000)),
pickMoreSeconds: Math.max(0, Math.ceil((Number(token.pickMoreFromTick) - currentGameTick) / ticksPerSecond)),
candidates: Object.values(pickResult).sort(
(left, right) =>
left.stats.leadership +
@@ -289,16 +298,18 @@ export const reserveNpcPossessionCandidates = async (options: {
refresh?: boolean;
keepIds?: number[];
now?: Date;
acceptedGameTick: number;
createdGameTick: number;
selectionObserver?: NpcPossessionSelectionObserver;
}): Promise<NpcPossessionReservation> => {
const { db, worldState, userId } = options;
requireNpcPossessionWorld(worldState);
const now = truncateToSeconds(options.now ?? new Date());
if (!Number.isSafeInteger(options.acceptedGameTick)) {
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.');
if (!Number.isSafeInteger(options.createdGameTick)) {
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 생성 tick이 올바르지 않습니다.');
}
await lockNpcPossession(db, userId);
const turnTermMinutes = resolveTurnTermMinutes(worldState);
const ticksPerSecond = GAME_TICKS_PER_TURN / (turnTermMinutes * 60);
if (await db.general.findFirst({ where: { userId }, select: { id: true } })) {
fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다');
@@ -324,14 +335,20 @@ export const reserveNpcPossessionCandidates = async (options: {
if (options.refresh) {
fail('CONFLICT', 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.');
}
return toReservation(inFlightToken, now);
return toReservation(inFlightToken, options.createdGameTick, ticksPerSecond);
}
if (existing && existing.validUntil.getTime() < now.getTime()) {
if (
existing &&
(existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick)
) {
await db.npcSelectionToken.deleteMany({
where: {
ownerUserId: userId,
nonce: existing.nonce,
validUntil: { lt: now },
OR: [
{ validUntilTick: null },
{ validUntilTick: { lt: BigInt(options.createdGameTick) } },
],
},
});
existing = null;
@@ -339,7 +356,7 @@ export const reserveNpcPossessionCandidates = async (options: {
const kept: Record<string, NpcPossessionCandidate> = {};
if (existing && options.refresh) {
if (now.getTime() < existing.pickMoreFrom.getTime()) {
if (existing.pickMoreFromTick === null || options.createdGameTick < Number(existing.pickMoreFromTick)) {
fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다');
}
const oldPick = parsePickResult(existing.pickResult);
@@ -352,16 +369,16 @@ export const reserveNpcPossessionCandidates = async (options: {
}
// Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다.
if (Object.keys(kept).length === Object.keys(oldPick).length) {
return toReservation(existing, now);
return toReservation(existing, options.createdGameTick, ticksPerSecond);
}
} else if (existing) {
return toReservation(existing, now);
return toReservation(existing, options.createdGameTick, ticksPerSecond);
}
const reservedRows = await db.npcSelectionToken.findMany({
where: {
ownerUserId: { not: userId },
validUntil: { gte: now },
validUntilTick: { gte: BigInt(options.createdGameTick) },
},
select: { pickResult: true },
});
@@ -397,16 +414,19 @@ export const reserveNpcPossessionCandidates = async (options: {
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
);
const selectionRng = new LiteHashDRBG(
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick)
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.createdGameTick)
);
const rng = options.selectionObserver?.onRandomDraw
? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw)
: new RandUtil(selectionRng);
const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw);
const turnTermMinutes = resolveTurnTermMinutes(worldState);
const validUntil = new Date(now.getTime() + Math.max(VALID_SECONDS, turnTermMinutes * 40) * 1000);
const validSeconds = Math.max(VALID_SECONDS, turnTermMinutes * 40);
const pickMoreSeconds = Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8));
const validUntilTick = options.createdGameTick + Math.round(validSeconds * ticksPerSecond);
const pickMoreFromTick = options.createdGameTick + Math.round(pickMoreSeconds * ticksPerSecond);
const validUntil = new Date(now.getTime() + validSeconds * 1000);
const refreshedPickMoreFrom = new Date(
now.getTime() + Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8)) * 1000
now.getTime() + pickMoreSeconds * 1000
);
const nonce = randomInt(0, 0x10000000);
@@ -415,7 +435,9 @@ export const reserveNpcPossessionCandidates = async (options: {
where: { ownerUserId: userId, nonce: existing.nonce },
data: {
validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: refreshedPickMoreFrom,
pickMoreFromTick: BigInt(pickMoreFromTick),
pickResult: pickResult as GamePrisma.InputJsonValue,
nonce,
},
@@ -423,7 +445,18 @@ export const reserveNpcPossessionCandidates = async (options: {
if (updated.count === 0) {
fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요');
}
return toReservation({ validUntil, pickMoreFrom: refreshedPickMoreFrom, pickResult, nonce }, now);
return toReservation(
{
validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: refreshedPickMoreFrom,
pickMoreFromTick: BigInt(pickMoreFromTick),
pickResult,
nonce,
},
options.createdGameTick,
ticksPerSecond
);
}
try {
@@ -431,7 +464,9 @@ export const reserveNpcPossessionCandidates = async (options: {
data: {
ownerUserId: userId,
validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: FIRST_PICK_MORE_FROM,
pickMoreFromTick: BigInt(options.createdGameTick),
pickResult: pickResult as GamePrisma.InputJsonValue,
nonce,
},
@@ -442,7 +477,18 @@ export const reserveNpcPossessionCandidates = async (options: {
}
throw error;
}
return toReservation({ validUntil, pickMoreFrom: FIRST_PICK_MORE_FROM, pickResult, nonce }, now);
return toReservation(
{
validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: FIRST_PICK_MORE_FROM,
pickMoreFromTick: BigInt(options.createdGameTick),
pickResult,
nonce,
},
options.createdGameTick,
ticksPerSecond
);
};
export const possessNpcGeneral = async (options: {
@@ -455,11 +501,13 @@ export const possessNpcGeneral = async (options: {
ownerLegacyPenalty?: Record<string, unknown>;
generalId: number;
tokenNonce: number;
acceptedAt: Date;
requestedAtWall: Date;
processingGameTick: number;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, generalId, acceptedAt } = options;
// queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다.
const tokenAcceptedAt = truncateToSeconds(acceptedAt);
const { db, world, worldState, userId, generalId, requestedAtWall } = options;
if (!Number.isSafeInteger(options.processingGameTick)) {
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 처리 tick이 올바르지 않습니다.');
}
requireNpcPossessionWorld(worldState);
await lockNpcPossession(db, userId);
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
@@ -475,7 +523,7 @@ export const possessNpcGeneral = async (options: {
where: {
ownerUserId: userId,
nonce: options.tokenNonce,
validUntil: { gte: tokenAcceptedAt },
validUntilTick: { gte: BigInt(options.processingGameTick) },
},
})) as NpcSelectionTokenRow | null;
if (!token) {
@@ -501,7 +549,7 @@ export const possessNpcGeneral = async (options: {
return fail('NOT_FOUND', '장수 등록에 실패했습니다.');
}
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, acceptedAt);
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, requestedAtWall);
world.updateGeneral(generalId, {
userId,
npcState: 1,
@@ -522,7 +570,7 @@ export const possessNpcGeneral = async (options: {
where: { generalId },
update: {
userId,
lastRefresh: acceptedAt,
lastRefresh: requestedAtWall,
refresh: 0,
refreshTotal: 0,
refreshScore: 0,
@@ -531,7 +579,7 @@ export const possessNpcGeneral = async (options: {
create: {
generalId,
userId,
lastRefresh: acceptedAt,
lastRefresh: requestedAtWall,
},
});
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId } });
+28 -11
View File
@@ -1,4 +1,9 @@
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra';
import {
createGamePostgresConnector,
GamePrisma,
type InputJsonValue,
type TurnEngineDatabaseClient,
} from '@sammo-ts/infra';
import { isRecord } from '@sammo-ts/common';
import { randomUUID } from 'node:crypto';
@@ -75,6 +80,7 @@ const buildTurnListFromRows = (
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & {
$queryRaw?<T>(query: GamePrisma.Sql): Promise<T>;
generalTurnRevision?: Pick<
NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>,
'findUnique' | 'createMany' | 'updateMany'
@@ -313,8 +319,17 @@ export class InMemoryReservedTurnStore {
}
}
private getLeaseExpiresAt(): Date {
return new Date(Date.now() + this.leaseDurationMs);
private getLeaseExpiresAt(nowWall: Date): Date {
return new Date(nowWall.getTime() + this.leaseDurationMs);
}
private async readDatabaseWallTime(prisma: ReservedTurnDatabaseClient = this.prisma): Promise<Date> {
if (!prisma.$queryRaw) return new Date();
const rows = await prisma.$queryRaw<Array<{ nowWall: Date }>>(GamePrisma.sql`
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "nowWall"
`);
if (!rows[0]) throw new Error('PostgreSQL did not return its authoritative wall clock.');
return rows[0].nowWall;
}
private async acquireGeneralLease(generalId: number): Promise<boolean> {
@@ -322,7 +337,7 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) {
return false;
}
const now = new Date();
const now = await this.readDatabaseWallTime();
const previous = (await revisionStore.findUnique({ where: { generalId } })) as {
leaseOwner: string | null;
leaseExpiresAt: Date | null;
@@ -331,7 +346,7 @@ export class InMemoryReservedTurnStore {
previous?.leaseOwner === this.leaseOwner &&
previous.leaseExpiresAt !== null &&
previous.leaseExpiresAt.getTime() > now.getTime();
const leaseExpiresAt = this.getLeaseExpiresAt();
const leaseExpiresAt = this.getLeaseExpiresAt(now);
let claimed = await revisionStore.updateMany({
where: {
generalId,
@@ -372,7 +387,7 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) {
return false;
}
const now = new Date();
const now = await this.readDatabaseWallTime();
const previous = (await revisionStore.findUnique({
where: { nationId_officerLevel: { nationId, officerLevel } },
})) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null;
@@ -380,7 +395,7 @@ export class InMemoryReservedTurnStore {
previous?.leaseOwner === this.leaseOwner &&
previous.leaseExpiresAt !== null &&
previous.leaseExpiresAt.getTime() > now.getTime();
const leaseExpiresAt = this.getLeaseExpiresAt();
const leaseExpiresAt = this.getLeaseExpiresAt(now);
let claimed = await revisionStore.updateMany({
where: {
nationId,
@@ -651,12 +666,13 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) {
return false;
}
const leaseExpiresAt = this.getLeaseExpiresAt();
const now = await this.readDatabaseWallTime(prisma);
const leaseExpiresAt = this.getLeaseExpiresAt(now);
const where = this.leasedGeneralIds.has(generalId)
? { generalId, leaseOwner: this.leaseOwner }
: {
generalId,
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
};
let claimed = await revisionStore.updateMany({
where,
@@ -721,13 +737,14 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) {
return false;
}
const leaseExpiresAt = this.getLeaseExpiresAt();
const now = await this.readDatabaseWallTime(prisma);
const leaseExpiresAt = this.getLeaseExpiresAt(now);
const where = this.leasedNationKeys.has(key)
? { nationId, officerLevel, leaseOwner: this.leaseOwner }
: {
nationId,
officerLevel,
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
};
let claimed = await revisionStore.updateMany({
where,
@@ -0,0 +1,125 @@
import { parseGameClockPhase } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
const safeNumber = (value: bigint, label: string): number => {
const result = Number(value);
if (!Number.isSafeInteger(result)) {
throw new Error(`${label} is outside the JavaScript safe integer range: ${value}.`);
}
return result;
};
/**
* Replays durable suspension ledgers into the already-running daemon before it
* handles a command or resumes scheduled turns. The caller must hold the clock
* operation advisory lock so the world row and ledger chain are one snapshot.
*/
export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld
): Promise<boolean> => {
const durable = await db.worldState.findFirst({
orderBy: { id: 'asc' },
select: {
id: true,
clockBaseTime: true,
clockTick: true,
clockMode: true,
clockWallAnchor: true,
lastTurnTick: true,
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
},
});
if (
!durable ||
!durable.clockBaseTime ||
durable.clockTick === null ||
!durable.clockWallAnchor ||
durable.lastTurnTick === null
) {
throw new Error('Runtime clock synchronization requires one fully initialized world clock.');
}
const before = world.getGameClockState();
const durableRevision = safeNumber(durable.clockRevision, 'durable clock revision');
const durableGeneration = safeNumber(durable.deadlineGeneration, 'durable deadline generation');
if (before.revision > durableRevision) {
throw new Error(`In-memory clock revision ${before.revision} is ahead of durable revision ${durableRevision}.`);
}
if (before.revision < durableRevision) {
const ledgers = await db.clockSuspension.findMany({
where: {
worldStateId: durable.id,
sourceRevision: { gte: BigInt(before.revision) },
targetRevision: { lte: durable.clockRevision },
status: { in: ['RECONCILING', 'APPLIED'] },
},
orderBy: { sourceRevision: 'asc' },
select: {
id: true,
sourceRevision: true,
targetRevision: true,
shiftTicks: true,
alignedTick: true,
resumeWallAt: true,
},
});
let expectedRevision = before.revision;
let expectedGeneration = before.deadlineGeneration;
for (const ledger of ledgers) {
const sourceRevision = safeNumber(ledger.sourceRevision, `clock suspension ${ledger.id} source revision`);
const targetRevision = safeNumber(ledger.targetRevision, `clock suspension ${ledger.id} target revision`);
if (sourceRevision !== expectedRevision || targetRevision !== sourceRevision + 1) {
throw new Error(
`Clock suspension ledger chain is discontinuous at ${ledger.id}: ` +
`expected ${expectedRevision}->${expectedRevision + 1}, found ${sourceRevision}->${targetRevision}.`
);
}
if (ledger.shiftTicks === null || ledger.alignedTick === null || !ledger.resumeWallAt) {
throw new Error(`Clock suspension ${ledger.id} has no completed reconciliation coordinate.`);
}
expectedGeneration += 1;
world.applyDurableClockReconciliation({
suspensionId: ledger.id,
sourceRevision,
targetRevision,
deadlineGeneration: expectedGeneration,
alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`),
shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`),
resumeWallAt: ledger.resumeWallAt,
});
expectedRevision = targetRevision;
}
if (expectedRevision !== durableRevision || expectedGeneration !== durableGeneration) {
throw new Error(
`Clock suspension ledger chain ended at ${expectedRevision}/${expectedGeneration}, ` +
`but durable clock is ${durableRevision}/${durableGeneration}.`
);
}
}
world.synchronizeDurableClockSnapshot({
baseTime: durable.clockBaseTime,
tick: safeNumber(durable.clockTick, 'durable clock tick'),
mode: durable.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: durable.clockWallAnchor,
lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'),
phase: parseGameClockPhase(durable.clockPhase),
revision: durableRevision,
deadlineGeneration: durableGeneration,
});
const after = world.getGameClockState();
return (
before.phase !== after.phase ||
before.revision !== after.revision ||
before.deadlineGeneration !== after.deadlineGeneration ||
before.tick !== after.tick ||
before.wallAnchor.getTime() !== after.wallAnchor.getTime()
);
};
+18 -10
View File
@@ -1,5 +1,6 @@
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import {
buildGameEventChannel,
@@ -89,8 +90,8 @@ const shiftTournamentClock = async (
};
const lockKey = `${stateKey}:mutation-lock`;
const token = randomUUID();
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
const deadline = performance.now() + 2_000;
while (performance.now() < deadline) {
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) {
try {
@@ -137,13 +138,20 @@ const ensureEngineCommand = async (
deltaMinutes,
};
try {
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
payload: asJson(command),
},
await db.$transaction(async (transaction) => {
const coordinate = await readInputEventClockCoordinate(transaction);
await transaction.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
payload: asJson(command),
acceptedGameTick: coordinate.gameTick,
acceptedClockRevision: coordinate.clockRevision,
acceptedDeadlineGeneration: coordinate.deadlineGeneration,
createdAt: coordinate.wallAt,
},
});
});
} catch (error) {
if (!isUniqueConflict(error)) {
+18 -10
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import {
buildGameEventChannel,
@@ -10,7 +11,7 @@ import {
type TurnDaemonCommand,
type TurnDaemonCommandResult,
} from '@sammo-ts/common';
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js';
@@ -97,13 +98,20 @@ const ensureEngineCommand = async (
settings,
};
try {
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
payload: asJson(command),
},
await db.$transaction(async (transaction) => {
const coordinate = await readInputEventClockCoordinate(transaction);
await transaction.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
payload: asJson(command),
acceptedGameTick: coordinate.gameTick,
acceptedClockRevision: coordinate.clockRevision,
acceptedDeadlineGeneration: coordinate.deadlineGeneration,
createdAt: coordinate.wallAt,
},
});
});
} catch (error) {
if (!isUniqueConflict(error)) throw error;
@@ -151,8 +159,8 @@ const reprojectTournamentClock = async (
};
const lockKey = `${stateKey}:mutation-lock`;
const token = randomUUID();
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
const deadline = performance.now() + 2_000;
while (performance.now() < deadline) {
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) {
try {
+57 -62
View File
@@ -268,13 +268,10 @@ const toReservationDto = (
world: InMemoryTurnWorld
): Promise<SelectPoolReservationDto> => {
const first = rows[0];
if (!first || (first.reservedUntilTick === null && first.reservedUntil === null)) {
if (!first || first.reservedUntilTick === null) {
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
}
const expiresAt =
first.reservedUntilTick === null
? first.reservedUntil!
: world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
const expiresAt = world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
const poolName = resolvePoolName(worldState);
if (!poolName || !SUPPORTED_POOLS.has(poolName)) {
throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
@@ -355,6 +352,23 @@ const readNextChangeAt = (generalMeta: unknown): Date | null => {
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const readNextChangeTick = (generalMeta: unknown): number | null => {
const raw = asRecord(generalMeta).next_change_tick;
const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN;
return Number.isSafeInteger(parsed) ? parsed : null;
};
const assertReselectionCooldown = (generalMeta: unknown, processingGameTick: number): void => {
const projection = readNextChangeAt(generalMeta);
const deadline = readNextChangeTick(generalMeta);
if (projection && deadline === null) {
fail('INTERNAL_SERVER_ERROR', '장수 재선택 cooldown의 GAME_TIME authority가 없습니다.');
}
if (deadline !== null && deadline > processingGameTick) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
};
const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
const meta = asRecord(worldState.meta);
const value = meta.hiddenSeed ?? meta.seed;
@@ -371,18 +385,8 @@ const toSafeReservationTick = (value: bigint | number, uniqueName: string): numb
return tick;
};
const resolveAcceptedGameTick = (world: InMemoryTurnWorld, now: Date): number => {
const tick = world.dateToGameTick(now);
if (!Number.isSafeInteger(tick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
}
return tick;
};
const isReservationActive = (row: SelectPoolRow, now: Date, nowTick: number): boolean =>
row.reservedUntilTick !== null
? toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick
: row.reservedUntil !== null && row.reservedUntil.getTime() >= now.getTime();
const isReservationActive = (row: SelectPoolRow, nowTick: number): boolean =>
row.reservedUntilTick !== null && toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick;
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
@@ -392,7 +396,6 @@ const requireSelectionToken = async (
db: DatabaseClient,
userId: string,
uniqueName: string,
now: Date,
nowTick: number
): Promise<SelectPoolRow> => {
const token = await db.selectPoolEntry.findFirst({
@@ -400,10 +403,7 @@ const requireSelectionToken = async (
ownerUserId: userId,
uniqueName,
generalId: null,
OR: [
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
reservedUntilTick: { gte: BigInt(nowTick) },
},
});
if (!token) {
@@ -438,15 +438,15 @@ export const reserveSelectionPool = async (options: {
worldState: WorldStateRow;
userId: string;
now?: Date;
acceptedGameTick?: number;
processingGameTick: number;
seedOwnerIdentity?: string | number;
}): Promise<SelectPoolReservationDto> => {
const { db, world, worldState, userId } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
const acceptedGameTick = options.acceptedGameTick ?? resolveAcceptedGameTick(world, now);
if (!Number.isSafeInteger(acceptedGameTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
const processingGameTick = options.processingGameTick;
if (!Number.isSafeInteger(processingGameTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
}
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
@@ -454,14 +454,11 @@ export const reserveSelectionPool = async (options: {
where: { userId },
select: { id: true, meta: true },
});
const nextChangeAt = general ? readNextChangeAt(general.meta) : null;
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
if (general) assertReselectionCooldown(general.meta, processingGameTick);
let currentRows = await synchronizeSelectionPoolWorld(db, world);
const existing = currentRows.filter(
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, now, acceptedGameTick)
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, processingGameTick)
);
if (existing.length > 0) {
return toReservationDto(existing, Boolean(general), worldState, world);
@@ -471,8 +468,8 @@ export const reserveSelectionPool = async (options: {
where: {
generalId: null,
OR: [
{ reservedUntilTick: { lt: BigInt(acceptedGameTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: now } },
{ reservedUntilTick: { lt: BigInt(processingGameTick) } },
{ reservedUntilTick: null, reservedUntil: { not: null } },
],
},
data: {
@@ -483,7 +480,7 @@ export const reserveSelectionPool = async (options: {
});
currentRows = await synchronizeSelectionPoolWorld(db, world);
const availableIds = new Set(
world.listGeneralPoolCandidates(now, acceptedGameTick)?.map((candidate) => candidate.poolEntryId) ?? []
world.listGeneralPoolCandidates(now, processingGameTick)?.map((candidate) => candidate.poolEntryId) ?? []
);
const available = currentRows.filter(
(row) =>
@@ -499,7 +496,7 @@ export const reserveSelectionPool = async (options: {
const rng = new RandUtil(
new LiteHashDRBG(
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, acceptedGameTick)
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, processingGameTick)
)
);
const poolName = resolvePoolName(worldState)!;
@@ -507,7 +504,7 @@ export const reserveSelectionPool = async (options: {
(row) =>
[row, calculateSelectionCandidateWeight(poolName, parseCandidate(row), true)] as [SelectPoolRow, number]
);
const reservedUntilTick = acceptedGameTick + RESERVATION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
const reservedUntilTick = processingGameTick + RESERVATION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
if (!Number.isSafeInteger(reservedUntilTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
}
@@ -573,7 +570,6 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor
const clearUnusedReservations = async (
db: DatabaseClient,
userId: string,
now: Date,
nowTick: number
): Promise<void> => {
await db.selectPoolEntry.updateMany({
@@ -582,7 +578,7 @@ const clearUnusedReservations = async (
OR: [
{ ownerUserId: userId },
{ reservedUntilTick: { lt: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: now } },
{ reservedUntilTick: null, reservedUntil: { not: null } },
],
},
data: {
@@ -711,6 +707,7 @@ export const createGeneralFromSelectionPool = async (options: {
now?: Date;
turnScheduleAt?: Date;
operationalAcceptedAt: Date;
processingGameTick: number;
seedOwnerIdentity?: string | number;
ownerPicture?: string;
ownerImageServer?: number;
@@ -719,7 +716,10 @@ export const createGeneralFromSelectionPool = async (options: {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
const nowTick = resolveAcceptedGameTick(world, now);
const nowTick = options.processingGameTick;
if (!Number.isSafeInteger(nowTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 생성 처리 tick이 안전한 정수 범위를 벗어났습니다.');
}
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
await synchronizeSelectionPoolWorld(db, world);
@@ -730,7 +730,7 @@ export const createGeneralFromSelectionPool = async (options: {
) {
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.');
}
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
const info = parseCandidate(token);
const poolName = resolvePoolName(worldState)!;
const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL;
@@ -772,9 +772,8 @@ export const createGeneralFromSelectionPool = async (options: {
const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
const age = 20;
const specialityAges = resolveSpecialityAges(worldState, age);
const nextChangeAt = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const nextChangeTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
const nextChangeAt = world.gameTickToDate(nextChangeTick);
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
@@ -808,6 +807,7 @@ export const createGeneralFromSelectionPool = async (options: {
dex5: isCentennial ? 0 : info.dex[4],
next_change: nextChangeAt.toISOString(),
nextChangeAt: nextChangeAt.toISOString(),
next_change_tick: nextChangeTick,
prestart_delete_after: prestartDeleteAfter.toISOString(),
...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}),
npc_org: 0,
@@ -900,10 +900,7 @@ export const createGeneralFromSelectionPool = async (options: {
id: token.id,
ownerUserId: userId,
generalId: null,
OR: [
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
reservedUntilTick: { gte: BigInt(nowTick) },
},
data: {
generalId,
@@ -920,7 +917,7 @@ export const createGeneralFromSelectionPool = async (options: {
update: { userId, lastRefresh: options.operationalAcceptedAt },
create: { generalId, userId, lastRefresh: options.operationalAcceptedAt },
});
await clearUnusedReservations(db, userId, now, nowTick);
await clearUnusedReservations(db, userId, nowTick);
await synchronizeSelectionPoolWorld(db, world);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
@@ -944,11 +941,15 @@ export const reselectGeneralFromSelectionPool = async (options: {
ownerDisplayName: string;
uniqueName: string;
now?: Date;
processingGameTick: number;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
const nowTick = resolveAcceptedGameTick(world, now);
const nowTick = options.processingGameTick;
if (!Number.isSafeInteger(nowTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 재선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
}
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
await synchronizeSelectionPoolWorld(db, world);
@@ -963,11 +964,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
if (persistedGeneral.id !== general.id) {
fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.');
}
const nextChangeAt = readNextChangeAt(general.meta);
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
assertReselectionCooldown(general.meta, nowTick);
const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
const info = parseCandidate(token);
const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL;
@@ -977,10 +975,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
id: token.id,
ownerUserId: userId,
generalId: null,
OR: [
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
reservedUntilTick: { gte: BigInt(nowTick) },
},
data: {
generalId: provisionalGeneralId,
@@ -1009,9 +1004,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
}
const cooldown = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const cooldownTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
const cooldown = world.gameTickToDate(cooldownTick);
const centennialBaseGeneral = isCentennial
? {
...general,
@@ -1041,6 +1035,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
: {}),
next_change: cooldown.toISOString(),
nextChangeAt: cooldown.toISOString(),
next_change_tick: cooldownTick,
...buildScenarioGeneralPoolClaimMeta(
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
now
@@ -1069,7 +1064,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
if (!updated) {
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
}
await clearUnusedReservations(db, userId, now, nowTick);
await clearUnusedReservations(db, userId, nowTick);
await synchronizeSelectionPoolWorld(db, world);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
@@ -14,8 +14,12 @@ interface TournamentState {
openMonth: number;
termSeconds: number;
nextAt: string;
nextTick?: number;
clockRevision?: number;
deadlineGeneration?: number;
bettingId?: number;
bettingCloseAt?: string;
bettingCloseTick?: number;
winnerId?: number;
bettingSettled?: boolean;
rewardSettled?: boolean;
@@ -76,6 +80,9 @@ export const createTournamentAutoStartHandler = (options: {
sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(options.profileName),
activeClockRevisionKey: `sammo:${options.profileName}:clock:active-revision`,
deadlineGenerationKey: `sammo:${options.profileName}:clock:deadline-generation`,
clockPhaseKey: `sammo:${options.profileName}:clock:phase`,
};
return {
onMonthChanged: async (context) => {
@@ -118,6 +125,8 @@ export const createTournamentAutoStartHandler = (options: {
previousState && Number.isFinite(previousState.termSeconds) && previousState.termSeconds > 0
? previousState.termSeconds
: resolveTermSeconds(state.tickSeconds);
const nextAt = new Date(now.getTime() + termSeconds * 60_000);
const clockState = world.getGameClockState();
const nextState: TournamentState = {
stage: 1,
phase: 0,
@@ -129,7 +138,10 @@ export const createTournamentAutoStartHandler = (options: {
// Ref startTournament() passes calcTournamentTerm()'s seconds
// value to DateInterval's minute field. Preserve that historical
// initial enrollment delay; later tournament phases use seconds.
nextAt: new Date(now.getTime() + termSeconds * 60_000).toISOString(),
nextAt: nextAt.toISOString(),
nextTick: world.dateToGameTick(nextAt),
clockRevision: clockState.revision,
deadlineGeneration: clockState.deadlineGeneration,
bettingId:
typeof previousState?.bettingId === 'number' && Number.isFinite(previousState.bettingId)
? previousState.bettingId + 1
@@ -142,12 +154,26 @@ export const createTournamentAutoStartHandler = (options: {
lastError: undefined,
lastErrorAt: undefined,
};
await writeTournamentProjection(redis, keys, [
{ key: keys.participantsKey, value: [] },
{ key: keys.matchesKey, value: [] },
{ key: keys.bettingKey, value: [] },
{ key: keys.stateKey, value: nextState },
]);
await writeTournamentProjection(
redis,
keys,
[
{ key: keys.participantsKey, value: [] },
{ key: keys.matchesKey, value: [] },
{ key: keys.bettingKey, value: [] },
{ key: keys.stateKey, value: nextState },
],
clockState.phase === 'RUNNING'
? {
activeRevisionKey: keys.activeClockRevisionKey,
deadlineGenerationKey: keys.deadlineGenerationKey,
phaseKey: keys.clockPhaseKey,
revision: clockState.revision,
deadlineGeneration: clockState.deadlineGeneration,
phase: 'RUNNING',
}
: undefined
);
const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0];
const emperor = world
+77 -5
View File
@@ -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';
@@ -219,6 +225,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
@@ -495,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);
@@ -548,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)) {
@@ -567,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: {
@@ -670,6 +713,9 @@ const createTurnDaemonRuntimeWithLease = async (
}));
let worldRef: InMemoryTurnWorld | null = null;
let redisConnector: RedisConnector | null = null;
let stopClockProjectionWorker = () => {};
let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined;
let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const monthlyActionModules = await loadActionModuleBundle(
@@ -874,6 +920,8 @@ const createTurnDaemonRuntimeWithLease = async (
},
};
takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt;
applyClockProjection = dbHooks.applyClockProjection;
synchronizeClockAuthority = dbHooks.synchronizeClockAuthority;
close = async () => {
if (auctionBidder) {
await auctionBidder.close();
@@ -924,9 +972,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;
@@ -948,6 +1004,7 @@ const createTurnDaemonRuntimeWithLease = async (
const baseClose = close;
close = async () => {
stopClockProjectionWorker();
await baseClose();
await neutralAuctionRegistrar.close();
if (redisConnector) {
@@ -976,6 +1033,7 @@ const createTurnDaemonRuntimeWithLease = async (
maxGenerals: 200,
catchUpCap: 1,
};
let lastObservedGatewayPause: boolean | null = null;
const lifecycle = new TurnDaemonLifecycle(
{
@@ -986,7 +1044,21 @@ const createTurnDaemonRuntimeWithLease = async (
stateStore,
processor,
hooks,
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
pauseGate: async () => {
if (turnDaemonLease?.isLost()) {
return true;
}
const gatewayPaused = (await pauseGate?.()) ?? false;
const phase = world.getGameClockState().phase;
const phaseNeedsSync = gatewayPaused
? phase !== 'SUSPENDED'
: phase === 'SUSPENDED' || phase === 'RECONCILING';
if (synchronizeClockAuthority && (lastObservedGatewayPause !== gatewayPaused || phaseNeedsSync)) {
await synchronizeClockAuthority();
}
lastObservedGatewayPause = gatewayPaused;
return gatewayPaused;
},
commandHandler,
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
// The exclusive fixture runner aborts the entire in-memory runtime
+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>;
}
@@ -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);
@@ -1,5 +1,9 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
import {
acquireGameSchemaAdvisoryXactLock,
enqueuePrivateMessageWebPush,
persistMessageEnvelope,
} from '@sammo-ts/infra';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
import {
@@ -119,22 +123,18 @@ interface HighestUnificationBidRow {
meta: unknown;
}
const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: MessageRecordDraft): Promise<number> => {
const rows = await transaction.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${draft.validUntil},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
const insertMessage = async (
transaction: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
draft: MessageRecordDraft
): Promise<number> => {
const clock = world.getGameClockState();
const id = await persistMessageEnvelope(transaction, draft, {
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
clockRevision: BigInt(clock.revision),
deadlineGeneration: BigInt(clock.deadlineGeneration),
expiresGameTick: null,
});
await enqueuePrivateMessageWebPush(transaction, draft, id);
return id;
};
@@ -252,7 +252,7 @@ const cancelPendingUniqueAuctions = async (
await sendMessage(
{
insertMessage: async (draft) => {
const messageId = await insertMessage(transaction, draft);
const messageId = await insertMessage(transaction, world, draft);
messageMailboxes.push(draft.mailbox);
return messageId;
},
+116 -32
View File
@@ -29,6 +29,7 @@ import {
normalizeTroopName,
resolveTroopSecretPermission,
resolveMessageTargetIcon,
readDiplomacyMeta,
type GeneralActionModule,
rollUniqueLottery,
type ItemModule,
@@ -146,6 +147,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 +158,7 @@ interface CommandHandlerContext {
reservedTurns?: InMemoryReservedTurnStore;
generalActionModules?: ReadonlyArray<GeneralActionModule>;
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
reconcileUnificationWait?: Parameters<typeof respondToActionableMessage>[0]['reconcileUnificationWait'];
}
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
@@ -177,6 +183,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'kick',
'appoint',
'voteReward',
'syncDiplomaticResponse',
] as const satisfies readonly TurnDaemonCommand['type'][];
type ActorBoundGeneralCommandType = (typeof ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST)[number];
@@ -273,14 +280,12 @@ const resolveSelectionCommandAcceptedAt = async (
world: InMemoryTurnWorld,
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }>
): Promise<Date> => {
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
if (command.acceptedGameTick !== undefined) {
return world.gameTickToDate(command.acceptedGameTick);
await resolveCommandAcceptedAt(db, command);
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) {
return world.gameTickToDate(processingGameTick);
}
if (command.acceptedGameAt !== undefined) {
return new Date(command.acceptedGameAt);
}
return world.getGameNow(operationalAcceptedAt);
throw new Error(`${command.type} requires an authoritative daemon processing game tick.`);
};
const resolveOperationalAcceptedAt = async (
@@ -407,9 +412,10 @@ async function handleNpcPossessGeneral(
throw new Error('NPC possession world state is missing.');
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = command.acceptedGameAt
? new Date(command.acceptedGameAt)
: ctx.world.getGameNow(operationalAcceptedAt);
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('npcPossessGeneral requires an authoritative daemon processing game tick.');
}
try {
return {
type: 'npcPossessGeneral',
@@ -423,7 +429,8 @@ async function handleNpcPossessGeneral(
...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}),
generalId: command.generalId,
tokenNonce: command.tokenNonce,
acceptedAt,
requestedAtWall: operationalAcceptedAt,
processingGameTick,
})),
};
} catch (error) {
@@ -451,13 +458,12 @@ async function handleSelectPoolCreate(
throw new Error('Selection-pool world state is missing.');
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt =
command.acceptedGameTick !== undefined
? ctx.world.gameTickToDate(command.acceptedGameTick)
: command.acceptedGameAt !== undefined
? new Date(command.acceptedGameAt)
: ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = acceptedAt;
try {
return {
type: 'selectPoolCreate',
@@ -476,6 +482,7 @@ async function handleSelectPoolCreate(
now: acceptedAt,
turnScheduleAt,
operationalAcceptedAt,
processingGameTick,
})),
};
} catch (error) {
@@ -514,7 +521,7 @@ async function handleSelectPoolReserve(
userId: command.userId,
seedOwnerIdentity: command.seedOwnerIdentity,
now: acceptedAt,
...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }),
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
}),
};
} catch (error) {
@@ -553,6 +560,7 @@ async function handleSelectPoolReselect(
ownerDisplayName: command.ownerDisplayName,
uniqueName: command.uniqueName,
now: acceptedAt,
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
})),
};
} catch (error) {
@@ -1820,6 +1828,8 @@ async function handleMessageRespond(
messageId: command.messageId,
response: command.response,
loadArchivedNationMaxId: ctx.loadArchivedNationMaxId,
clockOperationAuthority: ctx.clockOperationAuthority,
reconcileUnificationWait: ctx.reconcileUnificationWait,
});
return {
type: 'messageRespond',
@@ -1831,6 +1841,72 @@ async function handleMessageRespond(
};
}
async function handleSyncDiplomaticResponse(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'syncDiplomaticResponse' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx);
const action = await db.messageAction.findUnique({
where: { messageId: command.messageId },
select: { status: true },
});
if (action?.status !== 'RESOLVED') {
return {
type: 'syncDiplomaticResponse',
ok: false,
generalId: command.generalId,
messageId: command.messageId,
nations: 0,
diplomacy: 0,
cities: 0,
reason: '해결되지 않은 외교서신은 동기화할 수 없습니다.',
};
}
const nationIds = [...new Set(command.nationIds)];
const cityIds = [...new Set(command.cityIds)];
const [nations, diplomacy, cities] = await Promise.all([
db.nation.findMany({ where: { id: { in: nationIds } }, select: { id: true, meta: true } }),
nationIds.length === 0
? Promise.resolve([])
: db.diplomacy.findMany({
where: { srcNationId: { in: nationIds }, destNationId: { in: nationIds } },
select: { srcNationId: true, destNationId: true, stateCode: true, term: true, meta: true },
}),
db.city.findMany({ where: { id: { in: cityIds } }, select: { id: true, frontState: true } }),
]);
for (const nation of nations) {
ctx.world.updateNation(nation.id, { meta: asRecord(nation.meta) as Record<string, TriggerValue> });
}
for (const entry of diplomacy) {
const parsedMeta = readDiplomacyMeta(asRecord(entry.meta));
ctx.world.applyDiplomacyPatch({
srcNationId: entry.srcNationId,
destNationId: entry.destNationId,
patch: {
state: entry.stateCode,
term: entry.term,
dead: parsedMeta.dead,
meta: parsedMeta.meta as Record<string, TriggerValue>,
},
});
}
for (const city of cities) {
ctx.world.updateCity(city.id, { frontState: city.frontState });
}
return {
type: 'syncDiplomaticResponse',
ok: true,
generalId: command.generalId,
messageId: command.messageId,
nations: nations.length,
diplomacy: diplomacy.length,
cities: cities.length,
};
}
async function handleVacation(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'vacation' }>
@@ -2724,17 +2800,16 @@ type VotePollValidationRow = {
export const hasVotePollDeadlinePassed = (
poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>,
acceptedGameAt: Date,
acceptedGameTick: number
currentGameTick: number
): boolean => {
const endTick =
poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick);
return (
poll.closedAt !== null ||
(endTick !== null
? endTick < BigInt(acceptedGameTick)
: Boolean(poll.endAt && poll.endAt.getTime() < acceptedGameAt.getTime()))
);
if (poll.closedAt !== null) return true;
// No projection and no tick means an intentionally unbounded poll. A
// projection without its authoritative tick is a broken GAME deadline and
// therefore fails closed.
if (endTick === null) return poll.endAt !== null;
return endTick < BigInt(currentGameTick);
};
const parseVoteOptionCount = (value: unknown): number => {
@@ -2769,11 +2844,11 @@ const validateVoteSelectionInTransaction = async (
const poll = rows[0];
if (!poll) return '설문조사가 없습니다.';
const processingNow = ctx.world.getGameNow(new Date());
const acceptedGameTick = command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow);
const acceptedGameAt =
command.acceptedGameTick === undefined ? processingNow : ctx.world.gameTickToDate(command.acceptedGameTick);
if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) {
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
throw new Error('voteReward requires an authoritative daemon processing game tick.');
}
if (hasVotePollDeadlinePassed(poll, convertedProcessingTick)) {
return '설문조사가 종료되었습니다.';
}
@@ -3081,6 +3156,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 = {
@@ -3091,6 +3167,7 @@ export const createTurnDaemonCommandHandler = (options: {
reservedTurns: options.reservedTurns,
generalActionModules: options.generalActionModules,
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
reconcileUnificationWait: options.reconcileUnificationWait,
getImmediateGeneralActionExecutor: () => {
immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({
world: options.world,
@@ -3142,6 +3219,11 @@ export const createTurnDaemonCommandHandler = (options: {
handleInstantRetreat(ctx, command as Extract<TurnDaemonCommand, { type: 'instantRetreat' }>),
messageRespond: (command) =>
handleMessageRespond(ctx, command as Extract<TurnDaemonCommand, { type: 'messageRespond' }>),
syncDiplomaticResponse: (command) =>
handleSyncDiplomaticResponse(
ctx,
command as Extract<TurnDaemonCommand, { type: 'syncDiplomaticResponse' }>
),
vacation: (command) => handleVacation(ctx, command as Extract<TurnDaemonCommand, { type: 'vacation' }>),
setMySetting: (command) =>
handleSetMySetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setMySetting' }>),
@@ -3212,6 +3294,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);
@@ -3222,6 +3305,7 @@ export const createTurnDaemonCommandHandler = (options: {
return await handler(command);
} finally {
ctx.commandDb = undefined;
ctx.clockOperationAuthority = undefined;
}
},
};
+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: {
@@ -283,6 +283,7 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => {
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>,
actorUserId = command.userId
): Promise<void> => {
const clock = world.getGameClockState();
await db.inputEvent.create({
data: {
requestId: command.requestId!,
@@ -294,6 +295,12 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => {
leaseUntil: new Date('2026-07-31T09:30:00.000Z'),
attempts: 1,
payload: command as GamePrisma.InputJsonValue,
acceptedGameTick: BigInt(clock.tick),
acceptedClockRevision: BigInt(clock.revision),
acceptedDeadlineGeneration: BigInt(clock.deadlineGeneration),
processingGameTick: BigInt(clock.tick),
processingClockRevision: BigInt(clock.revision),
processingDeadlineGeneration: BigInt(clock.deadlineGeneration),
},
});
};
@@ -87,6 +87,7 @@ const destination = {
color: '#ffffff',
icon: '',
};
const requestId = 'actionable-message-request';
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
id: 29,
@@ -94,6 +95,10 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
type: 'private',
time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
actionType: action,
actionStatus: 'PENDING',
createdGameTick: 0n,
expiresGameTick: null,
message: {
src: source,
dest: destination,
@@ -106,10 +111,25 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
const buildDb = (rows: unknown[][]) => {
const queryRaw = vi.fn(async () => rows.shift() ?? []);
const updateMany = vi.fn(async () => ({ count: 1 }));
const actionUpdateMany = vi.fn(async () => ({ count: 1 }));
return {
db: { $queryRaw: queryRaw, message: { updateMany } } as unknown as GamePrisma.TransactionClient,
db: {
$queryRaw: queryRaw,
inputEvent: {
findUnique: vi.fn(async () => ({
actorUserId: actor.userId,
target: 'ENGINE',
eventType: 'messageRespond',
createdAt: new Date('2026-09-03T00:00:00.000Z'),
processingGameTick: 0n,
})),
},
message: { updateMany },
messageAction: { updateMany: actionUpdateMany },
} as unknown as GamePrisma.TransactionClient,
queryRaw,
updateMany,
actionUpdateMany,
};
};
@@ -118,6 +138,22 @@ const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({
});
describe('actionable message response', () => {
it('rejects a response without the authoritative durable command boundary', async () => {
const world = buildWorld();
const { db } = buildDb([[buildRow('scout')]]);
await expect(
respondToActionableMessage({
db,
world,
executor: buildExecutor(),
userId: actor.userId!,
generalId: actor.id,
messageId: 29,
response: true,
})
).rejects.toThrow('durable ENGINE input event requestId');
});
it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => {
const world = buildWorld();
const row = buildRow('scout');
@@ -128,6 +164,7 @@ describe('actionable message response', () => {
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -162,6 +199,7 @@ describe('actionable message response', () => {
db,
world,
executor: buildExecutor(false),
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -173,14 +211,8 @@ describe('actionable message response', () => {
expect(world.peekDirtyState().messages).toHaveLength(0);
});
it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => {
for (const row of [
buildRow('scout', { option: { action: 'scout', used: 1 } }),
{
...buildRow('scout'),
validUntil: new Date('0199-12-31T23:59:59.000Z'),
},
]) {
it('treats a legacy truthy used value as an invalid scout letter', async () => {
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
const world = buildWorld();
const { db, updateMany } = buildDb([[row]]);
const executor = buildExecutor();
@@ -190,6 +222,7 @@ describe('actionable message response', () => {
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -201,6 +234,24 @@ describe('actionable message response', () => {
}
});
it('treats an expired GAME_TIME action row as absent', async () => {
const world = buildWorld();
const { db } = buildDb([[]]);
await expect(
respondToActionableMessage({
db,
world,
executor: buildExecutor(),
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: 29,
response: true,
})
).resolves.toEqual({ ok: false, reason: '존재하지 않는 메시지입니다.' });
});
it("keeps PHP's special string-zero used value false", async () => {
const world = buildWorld();
const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
@@ -212,6 +263,7 @@ describe('actionable message response', () => {
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -233,6 +285,7 @@ describe('actionable message response', () => {
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -252,6 +305,7 @@ describe('actionable message response', () => {
db,
world,
executor: buildExecutor(),
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -271,6 +325,7 @@ describe('actionable message response', () => {
db,
world,
executor: buildExecutor(),
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -107,6 +107,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'],
});
const amount = finishImmediately ? 500 : 200;
const requestedAtWall = new Date('2026-08-23T00:00:00.000Z');
const result = await auctionBidder.bid(
{
type: 'auctionBid',
@@ -114,8 +115,9 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
auctionId: 31,
generalId: general.id,
amount,
acceptedGameTick: 100,
},
processingGameTick: 100,
requestedAtWall,
} as any,
commandDb as any
);
await auctionBidder.close();
@@ -125,7 +127,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
);
const insert = statements.find((query) => query.strings.join(' ').includes('INSERT INTO auction_bid'));
const update = statements.find((query) => query.strings.join(' ').includes('UPDATE auction'));
return { acceptedAt, processingAt, result, insert, update };
return { acceptedAt, processingAt, requestedAtWall, result, insert, update };
};
describe('resource auction Ref compatibility', () => {
@@ -135,21 +137,19 @@ describe('resource auction Ref compatibility', () => {
expect(hasAuctionClosePassed(auction, closeAt, 72_000_000)).toBe(false);
expect(hasAuctionClosePassed(auction, new Date(closeAt.getTime() + 1), 72_000_001)).toBe(true);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(false);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(true);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, new Date(closeAt.getTime() + 1), null)).toBe(true);
});
it('uses the durable API acceptance tick when queue processing crosses the close boundary', () => {
it('uses only the authoritative daemon processing tick at the close boundary', () => {
const closeAt = new Date('0190-02-01T00:00:00.000Z');
const auction = { closeAt, closeTick: 72_000_000n };
const world = {
dateToGameTick: () => 72_000_001,
gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)),
};
const processingNow = new Date(closeAt.getTime() + 1);
expect(hasAuctionBidClosePassed(auction, world, processingNow, 72_000_000)).toBe(false);
expect(hasAuctionBidClosePassed(auction, world, processingNow)).toBe(true);
expect(hasAuctionBidClosePassed(auction, world, 72_000_000)).toBe(false);
expect(hasAuctionBidClosePassed(auction, world, 72_000_001)).toBe(true);
expect(
normalizeTurnDaemonCommand({
requestId: 'auction-bid-accepted-tick',
@@ -161,23 +161,26 @@ describe('resource auction Ref compatibility', () => {
generalId: 7,
amount: 500,
acceptedGameTick: 72_000_000,
},
} as any,
})
).toMatchObject({ acceptedGameTick: 72_000_000 });
).not.toHaveProperty('acceptedGameTick');
});
it('uses the accepted logical time for delayed extension and persisted bid timestamps', async () => {
const { acceptedAt, processingAt, result, insert, update } = await runDelayedResourceBid(false);
const { acceptedAt, processingAt, requestedAtWall, result, insert, update } =
await runDelayedResourceBid(false);
expect(result).toMatchObject({ type: 'auctionBid', ok: true });
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
acceptedAt.getTime() + 100_000
);
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([acceptedAt]);
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([
acceptedAt,
requestedAtWall,
]);
expect(update?.values.filter((value): value is Date => value instanceof Date)).toEqual([
new Date(acceptedAt.getTime() + 100_000),
acceptedAt,
acceptedAt,
]);
expect(update?.values).not.toContain(processingAt);
});
@@ -27,6 +27,12 @@ import {
import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js';
import type { TurnGeneral } from '../src/turn/types.js';
const withDaemonBoundary = <T extends object>(command: T, processingGameTick = 72_000_000): T =>
Object.assign(command, {
processingGameTick,
requestedAtWall: new Date('2026-09-03T00:00:00.000Z'),
});
describe('unique auction inheritance log compatibility', () => {
it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => {
const userId = '4c2f2f6d-8a37-4f22-a4f9-1a6f5e4c22ec';
@@ -113,19 +119,20 @@ describe('unique auction inheritance log compatibility', () => {
}),
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
dateToGameTick: (date: Date) => Math.floor(date.getTime() / 1_000),
gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'),
updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
pushLog: () => {},
};
const result = await openAuction(
{
withDaemonBoundary({
type: 'auctionOpen',
userId: 'user-7',
auctionType: 'UNIQUE_ITEM',
generalId: general.id,
amount: 6_000,
itemKey: 'che_무기_12_칠성검',
},
}),
world as unknown as Parameters<typeof openAuction>[1],
db as unknown as NonNullable<Parameters<typeof openAuction>[2]>
);
@@ -192,6 +199,7 @@ describe('unique auction inheritance log compatibility', () => {
const world = {
getGameNow: () => closeAt,
dateToGameTick: () => 72_000_000,
gameTickToDate: () => closeAt,
pushLog: vi.fn(),
};
const finalizer = await createAuctionFinalizer({
@@ -201,12 +209,12 @@ describe('unique auction inheritance log compatibility', () => {
await expect(
finalizer.finalize(
{
withDaemonBoundary({
type: 'auctionFinalize',
auctionId: 31,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
}),
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
)
).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 });
@@ -241,6 +249,7 @@ describe('unique auction inheritance log compatibility', () => {
const world = {
getGameNow: () => closeAt,
dateToGameTick: () => nowTick,
gameTickToDate: () => closeAt,
};
const finalizer = await createAuctionFinalizer({
databaseUrl: 'postgresql://unused',
@@ -249,11 +258,23 @@ describe('unique auction inheritance log compatibility', () => {
const db = commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>;
await expect(
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }, db)
finalizer.finalize(
withDaemonBoundary(
{ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 },
71_999_999
),
db
)
).resolves.toMatchObject({ ok: false, reason: '경매 마감 시각이 아직 지나지 않았습니다.' });
nowTick = 72_000_000;
await expect(
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 }, db)
finalizer.finalize(
withDaemonBoundary(
{ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 },
72_000_000
),
db
)
).resolves.toMatchObject({ ok: false, reason: '경매 마감 세대가 변경되었습니다.' });
expect(executeRaw).not.toHaveBeenCalled();
@@ -276,12 +297,16 @@ describe('unique auction inheritance log compatibility', () => {
detail: { amount: 100 },
status: 'OPEN',
closeAt,
closeTick: null,
closeTick: 72_000_000n,
},
];
});
const commandDb = { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 0) };
const world = { getGameNow: () => closeAt, dateToGameTick: () => 72_000_000 };
const world = {
getGameNow: () => closeAt,
dateToGameTick: () => 72_000_000,
gameTickToDate: () => closeAt,
};
const finalizer = await createAuctionFinalizer({
databaseUrl: 'postgresql://unused',
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
@@ -289,7 +314,12 @@ describe('unique auction inheritance log compatibility', () => {
await expect(
finalizer.finalize(
{ type: 'auctionFinalize', auctionId: 31, expectedCloseAt: closeAt.toISOString() },
withDaemonBoundary({
type: 'auctionFinalize',
auctionId: 31,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}),
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
)
).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31');
@@ -317,6 +347,7 @@ describe('unique auction inheritance log compatibility', () => {
const queueMessage = vi.fn();
const world = {
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'),
getGeneralById: (id: number) => (id === bidder.id ? bidder : id === host.id ? host : null),
getNationById: () => ({ name: '촉', color: '#ff0000' }),
updateGeneral,
@@ -350,7 +381,7 @@ describe('unique auction inheritance log compatibility', () => {
await expect(
finalizer.finalize(
{ type: 'auctionFinalize', auctionId: 31 },
withDaemonBoundary({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }),
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
)
).resolves.toMatchObject({
@@ -46,6 +46,15 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
officerLevel: 4,
},
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
{
type: 'syncDiplomaticResponse',
requestId: 'syncDiplomaticResponse',
userId,
generalId: 7,
messageId: 31,
nationIds: [1, 2],
cityIds: [1],
},
];
const buildReadOnlyWorld = (ownerUserId: string) => {
@@ -181,6 +190,66 @@ describe('authenticated actor-bound command registry and execution', () => {
expect(mutation).not.toHaveBeenCalled();
});
it('refreshes the daemon world from the committed diplomatic response before the next turn', async () => {
const updateNation = vi.fn();
const applyDiplomacyPatch = vi.fn();
const updateCity = vi.fn();
const world = {
getGeneralById: vi.fn(() => ({ id: 7, userId: 'old-owner' })),
updateNation,
applyDiplomacyPatch,
updateCity,
} as unknown as InMemoryTurnWorld;
const db = {
inputEvent: {
findUnique: vi.fn(async () => ({
actorUserId: 'old-owner',
target: 'ENGINE',
eventType: 'syncDiplomaticResponse',
})),
},
messageAction: { findUnique: vi.fn(async () => ({ status: 'RESOLVED' })) },
nation: { findMany: vi.fn(async () => [{ id: 1, meta: { policy: 'balanced' } }]) },
diplomacy: {
findMany: vi.fn(async () => [
{ srcNationId: 1, destNationId: 2, stateCode: 7, term: 12, meta: { dead: 3 } },
]),
},
city: { findMany: vi.fn(async () => [{ id: 4, frontState: 2 }]) },
};
const handler = createTurnDaemonCommandHandler({ world });
await expect(
handler.handle(
{
type: 'syncDiplomaticResponse',
requestId: 'syncDiplomaticResponse',
userId: 'old-owner',
generalId: 7,
messageId: 31,
nationIds: [1, 2],
cityIds: [4],
},
{ db: db as never }
)
).resolves.toEqual({
type: 'syncDiplomaticResponse',
ok: true,
generalId: 7,
messageId: 31,
nations: 1,
diplomacy: 1,
cities: 1,
});
expect(updateNation).toHaveBeenCalledWith(1, { meta: { policy: 'balanced' } });
expect(applyDiplomacyPatch).toHaveBeenCalledWith({
srcNationId: 1,
destNationId: 2,
patch: { state: 7, term: 12, dead: 3, meta: {} },
});
expect(updateCity).toHaveBeenCalledWith(4, { frontState: 2 });
});
it('preserves direct in-memory invocation when no command database is supplied', async () => {
const updateGeneral = vi.fn();
const world = {
@@ -0,0 +1,501 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { GameClock } from '@sammo-ts/common';
import {
createGamePostgresConnector,
createRedisConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient,
type RedisConnector,
} from '@sammo-ts/infra';
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
const enabled =
process.env.CLOCK_RECONCILIATION_INTEGRATION === '1' &&
Boolean(process.env.DATABASE_URL) &&
Boolean(process.env.REDIS_URL);
const describeIntegration = enabled ? describe : describe.skip;
describeIntegration('durable clock reconciliation', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let redis: RedisConnector;
const clean = async (): Promise<void> => {
await redis.client.flushDb();
await db.$transaction([
db.clockProjectionOutbox.deleteMany(),
db.clockReconciliationParticipant.deleteMany(),
db.clockSuspension.deleteMany(),
db.inputEvent.deleteMany(),
db.vote.deleteMany(),
db.voteComment.deleteMany(),
db.votePoll.deleteMany(),
db.message.deleteMany(),
db.auctionBid.deleteMany(),
db.auction.deleteMany(),
db.npcSelectionToken.deleteMany(),
db.selectPoolEntry.deleteMany(),
db.general.deleteMany(),
db.turnDaemonLease.deleteMany(),
db.worldState.deleteMany(),
]);
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! });
db = connector.prisma;
disconnect = connector.disconnect;
redis = createRedisConnector({ url: process.env.REDIS_URL! });
await redis.connect();
});
afterAll(async () => {
await clean();
await redis.disconnect();
await disconnect?.();
});
beforeEach(async () => {
await clean();
});
it('preserves every remaining deadline and occurrence across a 65m17.250s exact gap', async () => {
const baseTime = new Date('2026-01-01T00:00:00.000Z');
const futureAnchor = new Date(Date.now() + 3_600_000);
const initialTick = 1_000_000;
const lastTurnTick = 900_000;
const clock = new GameClock({
baseTime,
tick: initialTick,
mode: 'realtime',
wallAnchor: futureAnchor,
turnSeconds: 600,
phase: 'RUNNING',
revision: 1,
});
const generalTicks = [initialTick + 1_234, initialTick + 36_000_123];
const reselectionTick = initialTick + 54_000_456;
const auctionCloseTick = initialTick + 72_000_777;
const messageOccurrenceTick = initialTick - 500;
const messageExpiryTick = initialTick + 90_000_999;
const voteStartTick = initialTick - 200;
const voteEndTick = initialTick + 18_000_321;
const poolTick = initialTick + 2_000_111;
const npcValidTick = initialTick + 3_000_222;
const npcMoreTick = initialTick + 1_000_333;
const world = await db.worldState.create({
data: {
scenarioCode: 'clock-test',
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
clockBaseTime: baseTime,
clockTick: BigInt(initialTick),
clockMode: 'realtime',
clockWallAnchor: futureAnchor,
lastTurnTick: BigInt(lastTurnTick),
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 7n,
meta: {
lastTurnTime: clock.tickToDate(lastTurnTick).toISOString(),
starttime: clock.tickToDate(initialTick + 100).toISOString(),
},
},
});
await db.general.createMany({
data: generalTicks.map((turnTick, index) => ({
id: index + 1,
name: `general-${index + 1}`,
turnTick: BigInt(turnTick),
turnTime: clock.tickToDate(turnTick),
recentWarTick: BigInt(initialTick - 100 - index),
recentWarTime: clock.tickToDate(initialTick - 100 - index),
meta:
index === 0
? {
next_change_tick: reselectionTick,
next_change: clock.tickToDate(reselectionTick).toISOString(),
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
}
: {},
})),
});
await db.auction.create({
data: {
type: 'BUY_RICE',
hostGeneralId: 1,
status: 'FINALIZING',
openTick: BigInt(initialTick - 300),
closeTick: BigInt(auctionCloseTick),
closeAt: clock.tickToDate(auctionCloseTick),
},
});
await db.message.create({
data: {
mailbox: 1,
type: 'private',
src: 1,
dest: 2,
time: clock.tickToDate(messageOccurrenceTick),
timeTick: BigInt(messageOccurrenceTick),
validUntil: clock.tickToDate(messageExpiryTick),
validUntilTick: BigInt(messageExpiryTick),
createdAtWall: new Date('2026-01-01T12:34:56.789Z'),
deleteUntilWall: new Date('2026-01-01T12:39:56.789Z'),
occurredGameTick: BigInt(messageOccurrenceTick),
message: {},
action: {
create: {
actionType: 'scout',
status: 'PENDING',
createdGameTick: BigInt(messageOccurrenceTick),
expiresGameTick: BigInt(messageExpiryTick),
clockRevision: 1n,
deadlineGeneration: 7n,
},
},
},
});
await db.votePoll.create({
data: {
title: 'clock vote',
options: ['yes', 'no'],
revealMode: 'AFTER_VOTE',
openerGeneralId: 1,
openerName: 'general-1',
startAt: clock.tickToDate(voteStartTick),
startTick: BigInt(voteStartTick),
endAt: clock.tickToDate(voteEndTick),
endTick: BigInt(voteEndTick),
},
});
await db.selectPoolEntry.create({
data: {
uniqueName: 'clock-pool',
reservedUntil: clock.tickToDate(poolTick),
reservedUntilTick: BigInt(poolTick),
info: {},
},
});
await db.npcSelectionToken.create({
data: {
ownerUserId: 'clock-user',
validUntil: clock.tickToDate(npcValidTick),
validUntilTick: BigInt(npcValidTick),
pickMoreFrom: clock.tickToDate(npcMoreTick),
pickMoreFromTick: BigInt(npcMoreTick),
pickResult: [],
nonce: 1,
},
});
const authority = { kind: 'OFFLINE' as const, profileName: 'clock-test', reason: 'integration fixture' };
const suspended = await startClockSuspension({
db,
suspensionId: 'clock-gap-65m17s250',
source: 'MAINTENANCE',
authority,
});
expect(suspended.cutTick).toBe(initialTick);
expect((await db.worldState.findUniqueOrThrow({ where: { id: world.id } })).clockPhase).toBe('SUSPENDED');
const resumeWallAt = new Date(suspended.cutWallAt.getTime() + 65 * 60_000 + 17_250);
const reconciled = await reconcileClockSuspension({
db,
suspensionId: suspended.suspensionId,
authority,
testResumeWallAt: resumeWallAt,
});
expect(reconciled).toMatchObject({
phase: 'RECONCILING',
sourceRevision: 1,
targetRevision: 2,
deadlineGeneration: 8,
gapTicks: 235_035_000,
shiftTicks: 235_035_000,
alignedTick: 236_035_000,
});
const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
await Promise.all([
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
db.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(),
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
db.clockProjectionOutbox.findMany(),
]);
const alignedTick = BigInt(reconciled.alignedTick);
expect(afterWorld).toMatchObject({
clockPhase: 'RECONCILING',
clockRevision: 2n,
deadlineGeneration: 8n,
clockTick: alignedTick,
lastTurnTick: BigInt(lastTurnTick + reconciled.shiftTicks),
});
expect(generals.map((general) => general.turnTick! - alignedTick)).toEqual(
generalTicks.map((tick) => BigInt(tick - initialTick))
);
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
);
expect(auction.closeTick! - alignedTick).toBe(BigInt(auctionCloseTick - initialTick));
expect(message.validUntilTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick));
expect(messageAction.expiresGameTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick));
expect(messageAction.createdGameTick).toBe(BigInt(messageOccurrenceTick));
expect(messageAction.clockRevision).toBe(2n);
expect(messageAction.deadlineGeneration).toBe(8n);
expect(message.createdAtWall).toEqual(new Date('2026-01-01T12:34:56.789Z'));
expect(message.deleteUntilWall).toEqual(new Date('2026-01-01T12:39:56.789Z'));
expect(vote.endTick! - alignedTick).toBe(BigInt(voteEndTick - initialTick));
expect(pool.reservedUntilTick! - alignedTick).toBe(BigInt(poolTick - initialTick));
expect(token.validUntilTick! - alignedTick).toBe(BigInt(npcValidTick - initialTick));
expect(token.pickMoreFromTick! - alignedTick).toBe(BigInt(npcMoreTick - initialTick));
expect(generals.map((general) => general.recentWarTick)).toEqual([
BigInt(initialTick - 100),
BigInt(initialTick - 101),
]);
expect(auction.openTick).toBe(BigInt(initialTick - 300));
expect(message.timeTick).toBe(BigInt(messageOccurrenceTick));
expect(vote.startTick).toBe(BigInt(voteStartTick));
expect(ledger.status).toBe('RECONCILING');
expect(outboxes).toHaveLength(1);
expect(outboxes[0]).toMatchObject({ status: 'PENDING', targetRevision: 2n });
const retried = await reconcileClockSuspension({
db,
suspensionId: suspended.suspensionId,
authority,
testResumeWallAt: new Date(resumeWallAt.getTime() + 10_000),
});
expect(retried).toEqual(reconciled);
expect(await db.clockProjectionOutbox.count()).toBe(1);
const keepParticipants = await db.clockReconciliationParticipant.findMany({ where: { policy: 'KEEP' } });
expect(keepParticipants.every((participant) => participant.beforeChecksum === participant.afterChecksum)).toBe(
true
);
await redis.client.set('sammo:clock-test:clock:active-revision', '1');
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'clock-projection-success' })).toBe(
'APPLIED'
);
expect(await redis.client.get('sammo:clock-test:clock:active-revision')).toBe('2');
expect(await redis.client.get('sammo:clock-test:clock:deadline-generation')).toBe('8');
expect(await redis.client.get('sammo:clock-test:clock:phase')).toBe('RUNNING');
expect(await redis.client.zRangeWithScores('sammo:clock-test:auction:timer', 0, -1)).toEqual([
{ value: String(auction.id), score: Number(auction.closeTick) },
]);
expect(await db.worldState.findUniqueOrThrow({ where: { id: world.id } })).toMatchObject({
clockPhase: 'RUNNING',
clockRevision: 2n,
});
expect(await db.clockProjectionOutbox.findFirstOrThrow()).toMatchObject({ status: 'APPLIED' });
});
it('rejects a live offline fence and preserves a turn deadline across an exact 24-hour gap', async () => {
const baseTime = new Date('2026-02-01T00:00:00.000Z');
const futureAnchor = new Date(Date.now() + 3_600_000);
const initialTick = 5 * 36_000_000;
const turnTick = initialTick + 17_000_007;
const clock = new GameClock({
baseTime,
tick: initialTick,
mode: 'realtime',
wallAnchor: futureAnchor,
turnSeconds: 3_600,
phase: 'RUNNING',
});
await db.worldState.create({
data: {
scenarioCode: 'clock-day-test',
currentYear: 180,
currentMonth: 1,
tickSeconds: 3_600,
clockBaseTime: baseTime,
clockTick: BigInt(initialTick),
clockMode: 'realtime',
clockWallAnchor: futureAnchor,
lastTurnTick: BigInt(initialTick),
clockPhase: 'RUNNING',
clockRevision: 3n,
deadlineGeneration: 2n,
},
});
await db.general.create({
data: { id: 1, name: 'day-general', turnTick: BigInt(turnTick), turnTime: clock.tickToDate(turnTick) },
});
const wallMessageCreatedAt = new Date('2026-01-15T12:00:00.000Z');
const wallMessageDeleteUntil = new Date('2026-01-15T12:05:00.000Z');
const wallMessage = await db.message.create({
data: {
mailbox: 0,
type: 'public',
src: 1,
dest: 0,
time: wallMessageCreatedAt,
validUntil: new Date('9999-12-31T00:00:00.000Z'),
createdAtWall: wallMessageCreatedAt,
deleteUntilWall: wallMessageDeleteUntil,
message: { src: {}, dest: {}, text: 'wall clock survives 24h suspension', option: {} },
},
});
await db.turnDaemonLease.create({
data: {
profile: 'clock-day-test',
ownerId: 'other-daemon',
fencingEpoch: 9n,
leaseUntil: new Date(Date.now() + 60_000),
},
});
const authority = {
kind: 'OFFLINE' as const,
profileName: 'clock-day-test',
reason: '24-hour integration fixture',
};
await expect(
startClockSuspension({
db,
suspensionId: 'clock-gap-24h',
source: 'MAINTENANCE',
authority,
})
).rejects.toThrow('daemon lease to be offline');
await db.turnDaemonLease.delete({ where: { profile: 'clock-day-test' } });
const suspended = await startClockSuspension({
db,
suspensionId: 'clock-gap-24h',
source: 'MAINTENANCE',
authority,
});
const reconciled = await reconcileClockSuspension({
db,
suspensionId: suspended.suspensionId,
authority,
testResumeWallAt: new Date(suspended.cutWallAt.getTime() + 24 * 60 * 60_000),
});
expect(reconciled).toMatchObject({
sourceRevision: 3,
targetRevision: 4,
gapTicks: 24 * 36_000_000,
shiftTicks: 24 * 36_000_000,
alignedTick: initialTick + 24 * 36_000_000,
});
const shifted = await db.general.findUniqueOrThrow({ where: { id: 1 } });
expect(shifted.turnTick! - BigInt(reconciled.alignedTick)).toBe(BigInt(turnTick - initialTick));
await expect(db.message.findUniqueOrThrow({ where: { id: wallMessage.id } })).resolves.toMatchObject({
createdAtWall: wallMessageCreatedAt,
deleteUntilWall: wallMessageDeleteUntil,
});
await redis.client.set('sammo:clock-day-test:clock:active-revision', '3');
const redisThenCrash = {
get: redis.client.get.bind(redis.client),
eval: async (script: string, options: { keys: string[]; arguments: string[] }) => {
await redis.client.eval(script, options);
throw new Error('fixture crash after Redis commit');
},
};
await expect(
applyNextClockProjection({ db, redis: redisThenCrash, workerId: 'clock-projection-crash' })
).rejects.toThrow('fixture crash after Redis commit');
expect(await redis.client.get('sammo:clock-day-test:clock:active-revision')).toBe('4');
expect(await db.worldState.findFirstOrThrow()).toMatchObject({ clockPhase: 'RECONCILING' });
expect(await db.clockProjectionOutbox.findFirstOrThrow()).toMatchObject({ status: 'FAILED', attempts: 1 });
await db.clockProjectionOutbox.updateMany({ data: { availableAt: new Date(0) } });
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'clock-projection-restart' })).toBe(
'RECOVERED'
);
expect(await db.worldState.findFirstOrThrow()).toMatchObject({ clockPhase: 'RUNNING', clockRevision: 4n });
expect(await db.clockProjectionOutbox.findFirstOrThrow()).toMatchObject({ status: 'APPLIED', attempts: 2 });
});
it('uses DB wall time despite host drift and does not deadlock with a general-access writer', async () => {
const [dbWall] = await db.$queryRaw<Array<{ now: Date }>>(GamePrisma.sql`
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS now
`);
const baseTime = new Date('2026-03-01T00:00:00.000Z');
const clock = new GameClock({
baseTime,
tick: 42,
mode: 'realtime',
wallAnchor: dbWall!.now,
turnSeconds: 600,
phase: 'RUNNING',
revision: 1,
});
const world = await db.worldState.create({
data: {
scenarioCode: 'clock-drift-deadlock-test',
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
clockBaseTime: baseTime,
clockTick: 42n,
clockMode: 'realtime',
clockWallAnchor: dbWall!.now,
lastTurnTick: 42n,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
await db.general.create({
data: { id: 1, name: 'lock-general', turnTick: 100n, turnTime: clock.tickToDate(100) },
});
let releaseWriter!: () => void;
let signalWriterLocked!: () => void;
const writerLocked = new Promise<void>((resolve) => {
signalWriterLocked = resolve;
});
const writerRelease = new Promise<void>((resolve) => {
releaseWriter = resolve;
});
const writer = db.$transaction(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
signalWriterLocked();
await writerRelease;
await transaction.$queryRaw(GamePrisma.sql`
SELECT id FROM world_state WHERE id = ${world.id} FOR UPDATE
`);
});
await writerLocked;
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(dbWall!.now.getTime() + 12 * 60 * 60_000);
try {
const suspensionPromise = startClockSuspension({
db,
suspensionId: 'clock-host-drift-deadlock',
source: 'MAINTENANCE',
authority: { kind: 'OFFLINE', profileName: 'clock-drift-deadlock-test', reason: 'fixture' },
});
releaseWriter();
const suspension = await Promise.race([
Promise.all([writer, suspensionPromise]).then(([, result]) => result),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('general-access/clock-operation deadlock')), 5_000)
),
]);
expect(Math.abs(suspension.cutWallAt.getTime() - dbWall!.now.getTime())).toBeLessThan(5_000);
expect(suspension.cutTick).toBeGreaterThanOrEqual(42);
expect(suspension.cutTick).toBeLessThan(42 + 60 * 60_000);
} finally {
dateNow.mockRestore();
releaseWriter();
}
});
});
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra';
import {
reconcileClockSuspension,
startClockSuspension,
type ClockReconciliationResult,
type ClockSuspensionResult,
} from '../src/turn/clockReconciliation.js';
const authority = { kind: 'OFFLINE' as const, profileName: 'retry-test', reason: 'isolated unit test' };
describe('serializable clock operation retries', () => {
it('retries a PostgreSQL serialization conflict while starting suspension', async () => {
const result: ClockSuspensionResult = {
suspensionId: 'retry-start',
phase: 'SUSPENDED',
sourceRevision: 7,
targetRevision: 8,
cutTick: 123,
cutWallAt: new Date('2026-09-03T10:00:00.000Z'),
};
const transaction = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error('could not serialize access'), { code: 'P2034' }))
.mockResolvedValueOnce(result);
const db = { $transaction: transaction } as unknown as GamePrismaClient;
await expect(
startClockSuspension({ db, suspensionId: result.suspensionId, source: 'MAINTENANCE', authority })
).resolves.toEqual(result);
expect(transaction).toHaveBeenCalledTimes(2);
});
it('retries a raw 40001 conflict while reconciling suspension', async () => {
const result: ClockReconciliationResult = {
suspensionId: 'retry-resume',
phase: 'RECONCILING',
sourceRevision: 7,
targetRevision: 8,
deadlineGeneration: 8,
gapTicks: 100,
catchUpTicks: 0,
shiftTicks: 100,
alignedTick: 223,
resumeWallAt: new Date('2026-09-03T10:10:00.000Z'),
};
const transaction = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error('SQLSTATE 40001'), { code: 'P2010', meta: { code: '40001' } })
)
.mockResolvedValueOnce(result);
const db = { $transaction: transaction } as unknown as GamePrismaClient;
await expect(reconcileClockSuspension({ db, suspensionId: result.suspensionId, authority })).resolves.toEqual(
result
);
expect(transaction).toHaveBeenCalledTimes(2);
});
it('does not retry a non-serialization failure', async () => {
const transaction = vi.fn().mockRejectedValue(new Error('authority denied'));
const db = { $transaction: transaction } as unknown as GamePrismaClient;
await expect(
startClockSuspension({ db, suspensionId: 'no-retry', source: 'MAINTENANCE', authority })
).rejects.toThrow('authority denied');
expect(transaction).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { TurnDaemonCommand } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra';
@@ -15,6 +15,55 @@ integration('database command queue', () => {
let close: (() => Promise<void>) | undefined;
let db: GamePrismaClient;
const cleanupFixtures = async (): Promise<void> => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'integration:engine:' } } });
await db.clockProjectionOutbox.deleteMany({
where: {
suspensionId: {
in: [
'integration-queue-revision-8-9',
'integration-maintenance-suspension',
'integration-unification-wait',
],
},
},
});
await db.clockSuspension.deleteMany({
where: {
id: {
in: [
'integration-queue-revision-8-9',
'integration-maintenance-suspension',
'integration-unification-wait',
],
},
},
});
await db.message.deleteMany({ where: { mailbox: 991_199 } });
await db.worldState.deleteMany({
where: { scenarioCode: { in: ['queue-clock-base', 'queue-clock-test', 'queue-unification-clock-test'] } },
});
};
const createClockFixture = async (): Promise<void> => {
await db.worldState.create({
data: {
scenarioCode: 'queue-clock-base',
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
clockBaseTime: new Date('0180-01-01T00:00:00.000Z'),
clockTick: 123n,
clockMode: 'manual',
clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'),
lastTurnTick: 123n,
clockPhase: 'MANUAL',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
@@ -25,10 +74,13 @@ integration('database command queue', () => {
});
});
beforeEach(async () => {
await cleanupFixtures();
await createClockFixture();
});
afterAll(async () => {
await db.inputEvent.deleteMany({
where: { requestId: { startsWith: 'integration:engine:' } },
});
await cleanupFixtures();
await close?.();
});
@@ -49,7 +101,16 @@ integration('database command queue', () => {
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
const commands = firstCommands.concat(secondCommands);
expect(commands).toEqual([{ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }]);
expect(commands).toEqual([
{
type: 'vacation',
requestId,
userId: 'user-7',
generalId: 7,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
@@ -104,7 +165,16 @@ integration('database command queue', () => {
await queue.initialize();
const commands = await queue.drain();
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, userId: 'user-8', generalId: 8 }]);
expect(commands).toEqual([
{
type: 'vacation',
requestId: expiredId,
userId: 'user-8',
generalId: 8,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
status: 'PROCESSING',
lockedBy: 'active-worker',
@@ -132,7 +202,14 @@ integration('database command queue', () => {
const stale = new DatabaseTurnDaemonCommandQueue(db);
for (const attempt of [1, 2, 3]) {
await expect(owner.drain()).resolves.toEqual([
{ type: 'vacation', requestId, userId: 'user-10', generalId: 10 },
{
type: 'vacation',
requestId,
userId: 'user-10',
generalId: 10,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
await stale.publishCommandError(requestId, new Error('stale worker failure'));
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
@@ -206,7 +283,13 @@ integration('database command queue', () => {
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([command]);
expect(claimed).toEqual([
{
...command,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
@@ -229,4 +312,504 @@ integration('database command queue', () => {
expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled();
});
it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld
? await db.worldState.update({
where: { id: existingWorld.id },
data: { clockPhase: 'SUSPENDED', clockRevision: 9n, deadlineGeneration: 4n, clockTick: 123n },
})
: await db.worldState.create({
data: {
scenarioCode: 'queue-clock-test',
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
clockPhase: 'SUSPENDED',
clockRevision: 9n,
deadlineGeneration: 4n,
clockTick: 123n,
},
});
const gameplayId = 'integration:engine:clock-gated-gameplay';
const statusId = 'integration:engine:clock-gated-status';
const staleId = 'integration:engine:clock-gated-stale';
await db.inputEvent.createMany({
data: [
{
requestId: gameplayId,
target: 'ENGINE',
eventType: 'vacation',
actorUserId: 'user-7',
acceptedGameTick: 100n,
acceptedClockRevision: 9n,
acceptedDeadlineGeneration: 4n,
payload: { type: 'vacation', requestId: gameplayId, userId: 'user-7', generalId: 7 },
},
{
requestId: statusId,
target: 'ENGINE',
eventType: 'getStatus',
acceptedGameTick: 100n,
acceptedClockRevision: 9n,
acceptedDeadlineGeneration: 4n,
payload: { type: 'getStatus', requestId: statusId },
},
{
requestId: staleId,
target: 'ENGINE',
eventType: 'vacation',
actorUserId: 'user-8',
acceptedGameTick: 90n,
acceptedClockRevision: 8n,
acceptedDeadlineGeneration: 3n,
payload: { type: 'vacation', requestId: staleId, userId: 'user-8', generalId: 8 },
},
],
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
expect(await queue.drain()).toEqual([
{
type: 'getStatus',
requestId: statusId,
processingGameTick: 100,
requestedAtWall: expect.any(Date),
},
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
status: 'PENDING',
processingClockRevision: null,
});
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } });
expect(await queue.drain()).toEqual([
{
type: 'vacation',
requestId: gameplayId,
userId: 'user-7',
generalId: 7,
processingGameTick: 100,
requestedAtWall: expect.any(Date),
},
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
status: 'PROCESSING',
processingGameTick: 100n,
processingClockRevision: 9n,
processingDeadlineGeneration: 4n,
});
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({
status: 'PENDING',
processingClockRevision: null,
});
await db.clockSuspension.deleteMany({ where: { id: 'integration-queue-revision-8-9' } });
await db.clockSuspension.create({
data: {
id: 'integration-queue-revision-8-9',
worldStateId: world.id,
source: 'MAINTENANCE',
policy: 'EXACT',
status: 'APPLIED',
sourceRevision: 8n,
targetRevision: 9n,
cutTick: 90n,
cutWallAt: new Date(),
resumeWallAt: new Date(),
rateTicksPerSecond: 60_000,
gapTicks: 33n,
shiftTicks: 33n,
alignedTick: 123n,
},
});
expect(await queue.drain()).toEqual([
{
type: 'vacation',
requestId: staleId,
userId: 'user-8',
generalId: 8,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({
status: 'PROCESSING',
acceptedGameTick: 90n,
acceptedClockRevision: 8n,
processingGameTick: 123n,
processingClockRevision: 9n,
processingDeadlineGeneration: 4n,
});
});
it('dequeues only tournament bet accounting commands while the game clock is suspended', async () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld
? await db.worldState.update({
where: { id: existingWorld.id },
data: { clockPhase: 'SUSPENDED', clockRevision: 19n, deadlineGeneration: 6n, clockTick: 321n },
})
: await db.worldState.create({
data: {
scenarioCode: 'queue-clock-test',
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
clockPhase: 'SUSPENDED',
clockRevision: 19n,
deadlineGeneration: 6n,
clockTick: 321n,
},
});
const resourceId = 'integration:engine:suspended-tournament-bet-resource';
const metaId = 'integration:engine:suspended-tournament-bet-meta';
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
await db.inputEvent.createMany({
data: [
{
requestId: resourceId,
target: 'ENGINE',
eventType: 'adjustGeneralResources',
payload: {
type: 'adjustGeneralResources',
requestId: resourceId,
reason: 'tournamentBet',
adjustments: [{ generalId: 7, goldDelta: -100 }],
},
},
{
requestId: metaId,
target: 'ENGINE',
eventType: 'adjustGeneralMeta',
payload: {
type: 'adjustGeneralMeta',
requestId: metaId,
reason: 'tournamentBet',
adjustments: [{ generalId: 7, metaDelta: { betgold: 100 } }],
},
},
{
requestId: rollbackId,
target: 'ENGINE',
eventType: 'adjustGeneralResources',
payload: {
type: 'adjustGeneralResources',
requestId: rollbackId,
reason: 'tournamentBetRollback',
adjustments: [{ generalId: 7, goldDelta: 100 }],
},
},
{
requestId: unrelatedId,
target: 'ENGINE',
eventType: 'adjustGeneralResources',
payload: {
type: 'adjustGeneralResources',
requestId: unrelatedId,
reason: 'otherMutation',
adjustments: [{ generalId: 7, goldDelta: -100 }],
},
},
],
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await expect(queue.drain()).resolves.toEqual([
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
expect.objectContaining({ type: 'adjustGeneralMeta', requestId: metaId, reason: 'tournamentBet' }),
expect.objectContaining({
type: 'adjustGeneralResources',
requestId: rollbackId,
reason: 'tournamentBetRollback',
}),
]);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
status: 'PROCESSING',
processingGameTick: 321n,
processingClockRevision: 19n,
processingDeadlineGeneration: 6n,
});
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: unrelatedId } })).resolves.toMatchObject({
status: 'PENDING',
processingClockRevision: null,
});
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RECONCILING' } });
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
});
it('dequeues fenced immediate user mutations during a maintenance suspension', async () => {
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
await db.worldState.update({
where: { id: world.id },
data: { clockPhase: 'SUSPENDED', clockRevision: 23n, deadlineGeneration: 9n, clockTick: 777n },
});
await db.clockSuspension.create({
data: {
id: 'integration-maintenance-suspension',
worldStateId: world.id,
source: 'MAINTENANCE',
policy: 'EXACT',
status: 'SUSPENDED',
sourceRevision: 23n,
targetRevision: 24n,
cutTick: 777n,
cutWallAt: new Date(),
rateTicksPerSecond: 60_000,
},
});
const commands: TurnDaemonCommand[] = [
{
type: 'inheritanceAction',
requestId: 'integration:engine:suspended-inheritance',
userId: 'user-7',
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 },
},
{
type: 'dropItem',
requestId: 'integration:engine:suspended-drop-item',
userId: 'user-7',
generalId: 7,
itemType: 'weapon',
},
{
type: 'changePermission',
requestId: 'integration:engine:suspended-permission',
userId: 'user-7',
generalId: 7,
isAmbassador: true,
targetGeneralIds: [8],
},
{
type: 'appoint',
requestId: 'integration:engine:suspended-appoint',
userId: 'user-7',
generalId: 7,
destGeneralId: 8,
destCityId: 1,
officerLevel: 2,
},
{
type: 'setNationSetting',
requestId: 'integration:engine:suspended-nation-setting',
userId: 'user-7',
generalId: 7,
nationId: 1,
mutation: { kind: 'rate', amount: 20 },
},
{
type: 'setNpcPolicy',
requestId: 'integration:engine:suspended-npc-policy',
userId: 'user-7',
generalId: 7,
nationId: 1,
expectedUpdatedAt: null,
mutation: { kind: 'nationPriority', priority: ['develop'] },
},
{
type: 'shiftSchedule',
requestId: 'integration:engine:suspended-shift-schedule',
actionId: '00000000-0000-4000-8000-000000000023',
deltaMinutes: -15,
},
];
await db.inputEvent.createMany({
data: commands.map((command) => ({
requestId: command.requestId!,
target: 'ENGINE' as const,
eventType: command.type,
actorUserId: 'userId' in command ? command.userId : null,
payload: command as GamePrisma.InputJsonValue,
})),
});
const blockedRequestId = 'integration:engine:suspended-vacation-still-gated';
await db.inputEvent.create({
data: {
requestId: blockedRequestId,
target: 'ENGINE',
eventType: 'vacation',
actorUserId: 'user-7',
payload: {
type: 'vacation',
requestId: blockedRequestId,
userId: 'user-7',
generalId: 7,
},
},
});
const claimed = await new DatabaseTurnDaemonCommandQueue(db).drain();
expect(claimed.map(({ type }) => type)).toEqual(commands.map(({ type }) => type));
for (const command of commands) {
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: command.requestId! } })
).resolves.toMatchObject({
status: 'PROCESSING',
processingGameTick: 777n,
processingClockRevision: 23n,
processingDeadlineGeneration: 9n,
});
}
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: blockedRequestId } })
).resolves.toMatchObject({
status: 'PENDING',
processingClockRevision: null,
});
});
it('dequeues only the invader decision while an UNIFICATION_WAIT suspension is active', async () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld
? await db.worldState.update({
where: { id: existingWorld.id },
data: { clockPhase: 'SUSPENDED', clockRevision: 31n, deadlineGeneration: 7n, clockTick: 900n },
})
: await db.worldState.create({
data: {
scenarioCode: 'queue-unification-clock-test',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
clockPhase: 'SUSPENDED',
clockRevision: 31n,
deadlineGeneration: 7n,
clockTick: 900n,
},
});
const message = await db.message.create({
data: {
mailbox: 991_199,
type: 'private',
src: 0,
dest: 991_199,
time: new Date(),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
message: { option: { action: 'raiseInvader', used: false } },
},
});
await db.messageAction.create({
data: {
messageId: message.id,
actionType: 'raiseInvader',
status: 'PENDING',
createdGameTick: 900n,
clockRevision: 31n,
deadlineGeneration: 7n,
},
});
const scoutMessage = await db.message.create({
data: {
mailbox: 991_199,
type: 'private',
src: 7,
dest: 991_199,
time: new Date(),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
message: { option: { action: 'scout', used: false } },
},
});
await db.messageAction.create({
data: {
messageId: scoutMessage.id,
actionType: 'scout',
status: 'PENDING',
createdGameTick: 900n,
clockRevision: 31n,
deadlineGeneration: 7n,
},
});
await db.clockSuspension.create({
data: {
id: 'integration-unification-wait',
worldStateId: world.id,
source: 'UNIFICATION_WAIT',
policy: 'EXACT',
status: 'SUSPENDED',
sourceRevision: 31n,
targetRevision: 32n,
cutTick: 900n,
cutWallAt: new Date(),
rateTicksPerSecond: 60_000,
},
});
const messageRequestId = 'integration:engine:unification-message';
const scoutRequestId = 'integration:engine:suspended-scout-response';
const gameplayRequestId = 'integration:engine:unification-gameplay';
await db.inputEvent.createMany({
data: [
{
requestId: messageRequestId,
target: 'ENGINE',
eventType: 'messageRespond',
actorUserId: 'user-991199',
acceptedGameTick: 900n,
acceptedClockRevision: 31n,
acceptedDeadlineGeneration: 7n,
payload: {
type: 'messageRespond',
requestId: messageRequestId,
userId: 'user-991199',
generalId: 991_199,
messageId: message.id,
response: true,
},
},
{
requestId: scoutRequestId,
target: 'ENGINE',
eventType: 'messageRespond',
actorUserId: 'user-991199',
acceptedGameTick: 900n,
acceptedClockRevision: 31n,
acceptedDeadlineGeneration: 7n,
payload: {
type: 'messageRespond',
requestId: scoutRequestId,
userId: 'user-991199',
generalId: 991_199,
messageId: scoutMessage.id,
response: true,
},
},
{
requestId: gameplayRequestId,
target: 'ENGINE',
eventType: 'vacation',
actorUserId: 'user-991199',
acceptedGameTick: 900n,
acceptedClockRevision: 31n,
acceptedDeadlineGeneration: 7n,
payload: {
type: 'vacation',
requestId: gameplayRequestId,
userId: 'user-991199',
generalId: 991_199,
},
},
],
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await expect(queue.drain()).resolves.toEqual([
{
type: 'messageRespond',
requestId: messageRequestId,
userId: 'user-991199',
generalId: 991_199,
messageId: message.id,
response: true,
processingGameTick: 900,
requestedAtWall: expect.any(Date),
},
]);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } })
).resolves.toMatchObject({ status: 'PENDING' });
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: scoutRequestId } })).resolves.toMatchObject({
status: 'PENDING',
});
});
});
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createGatewayAdminActionConsumer } from '../src/turn/gatewayAdminActions.js';
import { createGatewayProfileGate } from '../src/turn/gatewayProfileGate.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
@@ -109,4 +110,35 @@ integration('gateway runtime action consumer', () => {
expect(handler).toHaveBeenCalledTimes(2);
expect(onActionApplied).toHaveBeenCalledTimes(1);
});
it('does not overwrite a terminal operator status while reporting a daemon error', async () => {
const gate = await createGatewayProfileGate({
databaseUrl: databaseUrl!,
gatewayDatabaseUrl: databaseUrl!,
profileName,
});
try {
await db.gatewayProfile.update({
where: { profileName },
data: { status: 'RUNNING', lastError: null },
});
await gate.markPaused(new Error('running failure'));
expect(await db.gatewayProfile.findUniqueOrThrow({ where: { profileName } })).toMatchObject({
status: 'PAUSED',
lastError: 'running failure',
});
await db.gatewayProfile.update({
where: { profileName },
data: { status: 'STOPPED', lastError: null },
});
await gate.markPaused(new Error('late shutdown failure'));
expect(await db.gatewayProfile.findUniqueOrThrow({ where: { profileName } })).toMatchObject({
status: 'STOPPED',
lastError: null,
});
} finally {
await gate.close();
}
});
});
@@ -165,6 +165,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
db = connector.prisma;
disconnect = () => connector.disconnect();
await dropFailureConstraints();
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
@@ -198,6 +199,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
await hooks?.close();
if (db) {
await dropFailureConstraints();
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
@@ -263,6 +265,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
const createInputEvent = async (
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<void> => {
const clock = world.getGameClockState();
await db.inputEvent.create({
data: {
requestId: command.requestId!,
@@ -274,10 +277,22 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
leaseUntil: new Date('2026-08-24T01:00:00.000Z'),
attempts: 1,
payload: command as GamePrisma.InputJsonValue,
acceptedGameTick: BigInt(clock.tick),
acceptedClockRevision: BigInt(clock.revision),
acceptedDeadlineGeneration: BigInt(clock.deadlineGeneration),
processingGameTick: BigInt(clock.tick),
processingClockRevision: BigInt(clock.revision),
processingDeadlineGeneration: BigInt(clock.deadlineGeneration),
},
});
};
const assertStored = async (point: number, spent: number, logCount: number, messageCount: number) => {
const assertStored = async (
point: number,
spent: number,
logCount: number,
messageCount: number,
ledgerCount: number
) => {
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: actorUserId, key: 'previous' } },
@@ -292,6 +307,9 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
await expect(
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
).resolves.toBe(messageCount);
await expect(
db.inheritanceLedger.count({ where: { requestId: { startsWith: requestPrefix } } })
).resolves.toBe(ledgerCount);
};
const pointCommand = buildCommand('point', {
@@ -308,7 +326,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 });
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff');
await assertStored(10_000, 17, 0, 0);
await assertStored(10_000, 17, 0, 0, 0);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
).resolves.toMatchObject({
@@ -317,7 +335,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
});
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 });
await assertStored(9_800, 217, 1, 0);
await assertStored(9_800, 217, 1, 0, 1);
const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
await createInputEvent(rankCommand);
@@ -328,14 +346,14 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
`);
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
expect(world.peekDirtyState().messages).toEqual([]);
await assertStored(9_800, 217, 1, 0);
await assertStored(9_800, 217, 1, 0, 1);
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
await expect(execute(rankCommand)).resolves.toMatchObject({
ok: true,
remainPoint: 8_800,
ownerName: '레거시 소유자',
});
await assertStored(8_800, 1_217, 2, 2);
await assertStored(8_800, 1_217, 2, 2, 2);
const currentLog = await db.inheritanceLog.findFirstOrThrow({
where: { userId: actorUserId },
@@ -351,10 +369,10 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
`);
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique');
await assertStored(8_800, 1_217, 2, 2);
await assertStored(8_800, 1_217, 2, 2, 2);
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 3, 2);
await assertStored(5_800, 4_217, 3, 2, 3);
const freeStatCommand = buildCommand('free-stat', {
action: 'resetStat',
@@ -365,7 +383,21 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
});
await createInputEvent(freeStatCommand);
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 5, 2);
await assertStored(5_800, 4_217, 5, 2, 4);
const ledgers = await db.inheritanceLedger.findMany({
where: { requestId: { startsWith: requestPrefix } },
orderBy: { id: 'asc' },
});
expect(ledgers.map(({ action, cost, status }) => ({ action, cost, status }))).toEqual([
{ action: 'buyHiddenBuff', cost: 200, status: 'APPLIED' },
{ action: 'checkOwner', cost: 1_000, status: 'APPLIED' },
{ action: 'buyRandomUnique', cost: 3_000, status: 'APPLIED' },
{ action: 'resetStat', cost: 0, status: 'APPLIED' },
]);
expect(ledgers.every((row) => row.consumedAtWall instanceof Date && row.createdAtWall instanceof Date)).toBe(
true
);
const messages = await db.message.findMany({
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
@@ -133,6 +133,7 @@ describe('input event atomicity', () => {
ok: true,
auctionId: 3,
closeAt: '2026-01-01T00:10:00.000Z',
closeTick: 3_600_000,
};
let resolveResponse: (() => void) | undefined;
const responded = new Promise<void>((resolve) => {
@@ -126,6 +126,7 @@ const buildHarness = (options?: {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
clockPhase: 'RUNNING',
meta: {
hiddenSeed: 'raise-invader-fixture',
serverId: 'fixture-server',
@@ -410,6 +411,7 @@ describe('invader monthly actions', () => {
await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z'));
expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 });
expect(world.getState().clockPhase).toBe('COMPLETED');
expect(world.listEvents()).toHaveLength(0);
expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([
'<L><b>【이벤트】</b></>이민족을 모두 소탕했습니다!',
@@ -449,6 +451,7 @@ describe('invader monthly actions', () => {
await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z'));
expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 });
expect(world.getState().clockPhase).toBe('COMPLETED');
expect(world.listEvents()).toHaveLength(0);
expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([
'<L><b>【이벤트】</b></>중원은 이민족에 의해 혼란에 빠졌습니다.',

Some files were not shown because too many files have changed in this diff Show More