시간 도메인과 정지 중 메시지·베팅 경계 정리

This commit is contained in:
2026-09-03 16:01:19 +00:00
parent 10cddbb565
commit abceee8315
108 changed files with 3504 additions and 1473 deletions
+3 -2
View File
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
generalId: number, generalId: number,
input: OpenAuctionInput, input: OpenAuctionInput,
requestId?: string requestId?: string
): Promise<{ auctionId: number; closeAt: string }> => { ): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'auctionOpen', type: 'auctionOpen',
...(requestId ? { requestId } : {}), ...(requestId ? { requestId } : {}),
@@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async (
const closeAt = new Date(result.closeAt); const closeAt = new Date(result.closeAt);
const gameTime = await loadCurrentGameTime(ctx.db); const gameTime = await loadCurrentGameTime(ctx.db);
await ctx.redis.zAdd(timerKeys.timerKey, [ 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 { return {
auctionId: result.auctionId, auctionId: result.auctionId,
closeAt: result.closeAt, closeAt: result.closeAt,
closeTick: result.closeTick,
}; };
}; };
+5 -4
View File
@@ -10,18 +10,19 @@ interface RedisSortedSetClient {
} }
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => { export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
if (closeTick !== null && closeTick !== undefined) { void time;
void closeAt;
if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
const value = Number(closeTick); const value = Number(closeTick);
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`); if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
return value; return value;
}
return time.dateToTick(closeAt) ?? closeAt.getTime();
}; };
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => { export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
if (row.status === 'FINALIZING') { if (row.status === 'FINALIZING') {
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다. // 마감 판정은 이미 끝났으므로 원래 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); return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
}; };
+27 -58
View File
@@ -48,7 +48,7 @@ const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
interface AuctionFinalizeDeadline { interface AuctionFinalizeDeadline {
closeAt: Date; closeAt: Date;
closeTick: bigint | null; closeTick: bigint;
} }
interface AuctionFinalizeCommand { interface AuctionFinalizeCommand {
@@ -56,19 +56,10 @@ interface AuctionFinalizeCommand {
requestId: string; requestId: string;
auctionId: number; auctionId: number;
expectedCloseAt: string; expectedCloseAt: string;
expectedCloseTick?: number; expectedCloseTick: number;
} }
interface AuctionFinalizeEventRecord { const readSafeCloseTick = (closeTick: bigint): number => {
target: string;
eventType: string;
payload: unknown;
status: string;
result: unknown;
}
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
if (closeTick === null) return undefined;
const value = Number(closeTick); const value = Number(closeTick);
if (!Number.isSafeInteger(value)) { if (!Number.isSafeInteger(value)) {
throw new Error(`Auction close tick is unsafe: ${closeTick}`); throw new Error(`Auction close tick is unsafe: ${closeTick}`);
@@ -81,14 +72,7 @@ export const buildAuctionFinalizeRequestId = (
deadline: AuctionFinalizeDeadline, deadline: AuctionFinalizeDeadline,
retry = 0 retry = 0
): string => { ): string => {
const generation = const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
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()}`;
return retry > 0 ? `${base}:retry:${retry}` : base; return retry > 0 ? `${base}:retry:${retry}` : base;
}; };
@@ -101,7 +85,7 @@ const buildAuctionFinalizeCommand = (
requestId, requestId,
auctionId, auctionId,
expectedCloseAt: deadline.closeAt.toISOString(), expectedCloseAt: deadline.closeAt.toISOString(),
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }), expectedCloseTick: readSafeCloseTick(deadline.closeTick),
}); });
const isMatchingAuctionFinalizeEvent = ( const isMatchingAuctionFinalizeEvent = (
@@ -113,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = (
payload !== null && typeof payload === 'object' && !Array.isArray(payload) payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>) ? (payload as Record<string, unknown>)
: null; : null;
const expectedGenerationMatches = const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
payloadRecord?.expectedCloseTick !== undefined
? payloadRecord.expectedCloseTick === command.expectedCloseTick
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
return ( return (
event.target === 'ENGINE' && event.target === 'ENGINE' &&
event.eventType === command.type && event.eventType === command.type &&
@@ -192,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: {
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') { if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
continue; 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 deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline); 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({ const events = await options.db.inputEvent.findMany({
where: { 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 }, select: { requestId: true, target: true, eventType: true, payload: true, status: true },
orderBy: { sequence: 'desc' }, orderBy: { sequence: 'desc' },
@@ -217,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: {
timers.push({ timers.push({
score: score:
row.status === 'FINALIZING' 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), : resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
value: String(row.id), value: String(row.id),
}); });
@@ -281,10 +264,10 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const }; return { status: 'IGNORED' as const };
} }
if (current.status === 'OPEN') { if (current.status === 'OPEN') {
const isDue = if (current.closeTick === null || nowTick === null) {
current.closeTick !== null && nowTick !== null throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
? current.closeTick <= BigInt(nowTick) }
: current.closeTick === null && current.closeAt.getTime() <= now.getTime(); const isDue = current.closeTick <= BigInt(nowTick);
if (!isDue) { if (!isDue) {
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick }; return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
} }
@@ -293,24 +276,15 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const }; 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 }; const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) { for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry); const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry); const existing = await transaction.inputEvent.findUnique({
const candidateRequestIds = [...new Set([requestId, legacyRequestId])]; where: { requestId },
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 }, select: { target: true, eventType: true, payload: true, status: true, result: true },
}); });
if (existing) { const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
existingRequestId = candidateRequestId;
break;
}
}
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
if (!existing) { if (!existing) {
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId); const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
await transaction.inputEvent.create({ await transaction.inputEvent.create({
@@ -319,26 +293,19 @@ export const processDueAuctionId = async (options: {
target: 'ENGINE', target: 'ENGINE',
eventType: nextCommand.type, eventType: nextCommand.type,
payload: { ...nextCommand }, payload: { ...nextCommand },
...(nowTick === null ? {} : { acceptedGameTick: BigInt(nowTick) }),
...(options.expectedClockRevision === undefined
? {}
: { acceptedClockRevision: BigInt(options.expectedClockRevision) }),
...(options.expectedDeadlineGeneration === undefined
? {}
: { acceptedDeadlineGeneration: BigInt(options.expectedDeadlineGeneration) }),
}, },
}); });
return { status: 'PENDING' as const }; return { status: 'PENDING' as const };
} }
if (!isMatchingAuctionFinalizeEvent(existing, command)) { 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') { if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
return { status: 'PENDING' as const }; return { status: 'PENDING' as const };
} }
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) { if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
throw new Error( throw new Error(
`Auction remained ${current.status} after successful durable event: ${existingRequestId}` `Auction remained ${current.status} after successful durable event: ${requestId}`
); );
} }
} }
@@ -386,12 +353,13 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() }, { name: 'auction-worker-postgres', run: () => postgres.disconnect() },
]); ]);
let nextResyncAt = Date.now(); let nextResyncAt = performance.now();
const pendingFinalizationIds = new Set<number>(); const pendingFinalizationIds = new Set<number>();
try { try {
while (!control.signal.aborted) { while (!control.signal.aborted) {
const operationalNowMs = Date.now(); const operationalNowMs = Date.now();
const operationalElapsedMs = performance.now();
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs)); const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
const gameNowMs = gameTime.now.getTime(); const gameNowMs = gameTime.now.getTime();
const dueScore = gameTime.tick ?? gameNowMs; const dueScore = gameTime.tick ?? gameNowMs;
@@ -406,9 +374,9 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs); await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
continue; continue;
} }
if (operationalNowMs >= nextResyncAt) { if (operationalElapsedMs >= nextResyncAt) {
await seedAuctionTimers(postgres.prisma, redis.client, keys); await seedAuctionTimers(postgres.prisma, redis.client, keys);
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs; nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
} }
if (pendingFinalizationIds.size > 0) { if (pendingFinalizationIds.size > 0) {
const reconciliation = await reconcilePendingAuctionTimers({ const reconciliation = await reconcilePendingAuctionTimers({
@@ -483,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
await closeResources(); await closeResources();
} }
}; };
import { performance } from 'node:perf_hooks';
+16 -39
View File
@@ -1,16 +1,15 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
readInputEventClockCoordinate, readInputEventClockCoordinate,
type DatabaseClient, type DatabaseClient,
type GamePrisma, type GamePrisma,
type InputEventClockCoordinate,
} from '@sammo-ts/infra'; } from '@sammo-ts/infra';
import type { TurnDaemonTransport } from './transport.js'; import type { TurnDaemonTransport } from './transport.js';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.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; const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
@@ -88,9 +87,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
async sendCommand(command: TurnDaemonCommand): Promise<string> { async sendCommand(command: TurnDaemonCommand): Promise<string> {
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID(); const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand; const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
if (durableCommand.type === 'npcPossessGeneral') { // Rolling-upgrade compatibility: older API versions supplied game
delete durableCommand.acceptedGameAt; // 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') { if (command.type === 'npcPossessGeneral') {
const existing = await this.db.inputEvent.findUnique({ const existing = await this.db.inputEvent.findUnique({
where: { requestId }, where: { requestId },
@@ -112,12 +114,11 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
const coordinate = await readInputEventClockCoordinate(transaction); const coordinate = await readInputEventClockCoordinate(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global'); await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`); await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
const acceptedGameAt = coordinate.gameAt;
const token = await transaction.npcSelectionToken.findFirst({ const token = await transaction.npcSelectionToken.findFirst({
where: { where: {
ownerUserId: command.userId, ownerUserId: command.userId,
nonce: command.tokenNonce, nonce: command.tokenNonce,
validUntil: { gte: acceptedGameAt }, validUntilTick: { gte: coordinate.gameTick },
}, },
select: { pickResult: true }, select: { pickResult: true },
}); });
@@ -132,11 +133,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
) { ) {
return '선택한 장수가 목록에 없습니다.'; return '선택한 장수가 목록에 없습니다.';
} }
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = { await this.createInputEvent(transaction, durableCommand, requestId);
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
acceptedGameAt: acceptedGameAt.toISOString(),
};
await this.createInputEvent(transaction, acceptedCommand, requestId, coordinate);
return null; return null;
}); });
if (rejectionReason) { if (rejectionReason) {
@@ -145,8 +142,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
} else { } else {
if (this.db.$transaction) { if (this.db.$transaction) {
await this.db.$transaction(async (transaction) => { await this.db.$transaction(async (transaction) => {
const coordinate = await readInputEventClockCoordinate(transaction); await this.createInputEvent(transaction, durableCommand, requestId);
await this.createInputEvent(transaction, durableCommand, requestId, coordinate);
}); });
} else { } else {
await this.createInputEvent(this.db, durableCommand, requestId); await this.createInputEvent(this.db, durableCommand, requestId);
@@ -178,21 +174,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
private async createInputEvent( private async createInputEvent(
db: DatabaseClient, db: DatabaseClient,
command: TurnDaemonCommand, command: TurnDaemonCommand,
requestId: string, requestId: string
coordinate?: InputEventClockCoordinate
): Promise<void> { ): Promise<void> {
const gameTime = coordinate ? null : await loadCurrentGameTime(db, new Date());
const commandAcceptedTick = Reflect.get(command, 'acceptedGameTick');
const acceptedGameTick =
typeof commandAcceptedTick === 'number' && Number.isSafeInteger(commandAcceptedTick)
? commandAcceptedTick
: coordinate
? Number(coordinate.gameTick)
: gameTime!.tick;
const acceptedClockRevision = coordinate ? Number(coordinate.clockRevision) : gameTime!.revision;
const acceptedDeadlineGeneration = coordinate
? Number(coordinate.deadlineGeneration)
: gameTime!.deadlineGeneration;
await db.inputEvent.create({ await db.inputEvent.create({
data: { data: {
requestId, requestId,
@@ -200,14 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
eventType: command.type, eventType: command.type,
payload: asJson(command), payload: asJson(command),
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null, actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
...(acceptedGameTick === null ? {} : { acceptedGameTick: BigInt(acceptedGameTick) }), // PostgreSQL owns created_at WALL_TIME. ENGINE assigns the
...(acceptedClockRevision === null || acceptedClockRevision === undefined // authoritative game coordinate when the daemon claims it.
? {}
: { acceptedClockRevision: BigInt(acceptedClockRevision) }),
...(acceptedDeadlineGeneration === null || acceptedDeadlineGeneration === undefined
? {}
: { acceptedDeadlineGeneration: BigInt(acceptedDeadlineGeneration) }),
...(coordinate ? { createdAt: coordinate.wallAt } : {}),
}, },
}); });
} }
@@ -224,8 +201,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
} }
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> { private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs); const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
while (Date.now() < deadline) { while (performance.now() < deadline) {
const event = await this.db.inputEvent.findUnique({ const event = await this.db.inputEvent.findUnique({
where: { requestId }, where: { requestId },
select: { status: true, result: true, error: true }, select: { status: true, result: true, error: true },
@@ -236,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
if (event?.status === 'FAILED') { if (event?.status === 'FAILED') {
throw new FailedTurnDaemonCommandError(requestId, event.error); 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; return null;
} }
+66 -65
View File
@@ -25,9 +25,6 @@ interface LockedInputEvent {
status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED'; status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED';
result: GamePrisma.JsonValue | null; result: GamePrisma.JsonValue | null;
attempts: number; attempts: number;
acceptedGameTick: bigint | null;
acceptedClockRevision: bigint | null;
acceptedDeadlineGeneration: bigint | null;
} }
type InputEventOutcome<T> = type InputEventOutcome<T> =
@@ -37,8 +34,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
$executeRawUnsafe(query: string): Promise<number>; $executeRawUnsafe(query: string): Promise<number>;
}; };
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const canonicalJson = (value: unknown): string => const canonicalJson = (value: unknown): string =>
JSON.stringify(value, (_key, entry: unknown) => { JSON.stringify(value, (_key, entry: unknown) => {
if (typeof entry === 'bigint') { if (typeof entry === 'bigint') {
@@ -106,9 +101,9 @@ const insertPendingIfAbsent = async (
${options.actorUserId}, ${options.actorUserId},
'PENDING'::"InputEventStatus", 'PENDING'::"InputEventStatus",
0, 0,
(SELECT clock_tick FROM world_state ORDER BY id ASC LIMIT 1), NULL,
(SELECT clock_revision FROM world_state ORDER BY id ASC LIMIT 1), NULL,
(SELECT deadline_generation FROM world_state ORDER BY id ASC LIMIT 1), NULL,
CURRENT_TIMESTAMP AT TIME ZONE 'UTC' CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
ON CONFLICT (request_id) DO NOTHING ON CONFLICT (request_id) DO NOTHING
@@ -126,10 +121,7 @@ const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise<Lo
actor_user_id AS "actorUserId", actor_user_id AS "actorUserId",
status, status,
result, result,
attempts, attempts
accepted_game_tick AS "acceptedGameTick",
accepted_clock_revision AS "acceptedClockRevision",
accepted_deadline_generation AS "acceptedDeadlineGeneration"
FROM input_event FROM input_event
WHERE request_id = ${requestId} WHERE request_id = ${requestId}
FOR UPDATE FOR UPDATE
@@ -168,26 +160,24 @@ const isMatchingIdentity = (
const claimInputEvent = async ( const claimInputEvent = async (
db: DatabaseClient, db: DatabaseClient,
requestId: string, requestId: string,
payloadIdentity: ApiInputPayloadIdentity, payloadIdentity: ApiInputPayloadIdentity
row: LockedInputEvent
): Promise<void> => { ): Promise<void> => {
await db.inputEvent.update({ await db.$executeRaw(GamePrisma.sql`
where: { requestId }, UPDATE input_event
data: { SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
payload: asJson(payloadIdentity), status = 'PROCESSING'::"InputEventStatus",
status: 'PROCESSING', result = NULL,
result: GamePrisma.DbNull, error = NULL,
error: null, attempts = attempts + 1,
attempts: { increment: 1 }, locked_by = NULL,
lockedBy: null, lease_until = NULL,
leaseUntil: null, processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
processingAt: new Date(), processing_game_tick = NULL,
processingGameTick: row.acceptedGameTick, processing_clock_revision = NULL,
processingClockRevision: row.acceptedClockRevision, processing_deadline_generation = NULL,
processingDeadlineGeneration: row.acceptedDeadlineGeneration, completed_at = NULL
completedAt: null, WHERE request_id = ${requestId}
}, `);
});
}; };
const markUnexpectedFailure = async ( const markUnexpectedFailure = async (
@@ -198,6 +188,7 @@ const markUnexpectedFailure = async (
actorUserId: string | null; actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity; payloadIdentity: ApiInputPayloadIdentity;
error: unknown; error: unknown;
acquireClockFence: boolean;
} }
): Promise<void> => { ): Promise<void> => {
if (!db.$transaction) return; if (!db.$transaction) return;
@@ -205,7 +196,9 @@ const markUnexpectedFailure = async (
try { try {
await db.$transaction(async (transaction) => { await db.$transaction(async (transaction) => {
if (options.acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, options); await insertPendingIfAbsent(transaction, options);
const row = await lockInputEvent(transaction, options.requestId); const row = await lockInputEvent(transaction, options.requestId);
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options); const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
@@ -214,20 +207,19 @@ const markUnexpectedFailure = async (
// late failure recorder must never replace its durable success. // late failure recorder must never replace its durable success.
return; return;
} }
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { requestId: options.requestId }, UPDATE input_event
data: { SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
payload: asJson(options.payloadIdentity), status = 'FAILED'::"InputEventStatus",
status: 'FAILED', result = NULL,
result: GamePrisma.DbNull, error = ${message},
error: message, attempts = attempts + 1,
attempts: { increment: 1 }, locked_by = NULL,
lockedBy: null, lease_until = NULL,
leaseUntil: null, processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
processingAt: new Date(), completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
completedAt: new Date(), WHERE request_id = ${options.requestId}
}, `);
});
}); });
} catch { } catch {
// Preserve the transaction failure that the caller actually observed. If // Preserve the transaction failure that the caller actually observed. If
@@ -242,10 +234,12 @@ export const executeInputEvent = async <T>(options: {
eventType: string; eventType: string;
payload: unknown; payload: unknown;
actorUserId?: string | null; actorUserId?: string | null;
acquireClockFence?: boolean;
execute(db: DatabaseClient): Promise<T>; execute(db: DatabaseClient): Promise<T>;
}): Promise<T> => { }): Promise<T> => {
const { db, requestId, eventType, payload, execute } = options; const { db, requestId, eventType, payload, execute } = options;
const actorUserId = options.actorUserId ?? null; const actorUserId = options.actorUserId ?? null;
const acquireClockFence = options.acquireClockFence !== false;
const payloadIdentity = createApiInputPayloadIdentity(payload); const payloadIdentity = createApiInputPayloadIdentity(payload);
if (!db.$transaction) { if (!db.$transaction) {
return execute(db); return execute(db);
@@ -255,7 +249,9 @@ export const executeInputEvent = async <T>(options: {
let outcome: InputEventOutcome<T>; let outcome: InputEventOutcome<T>;
try { try {
outcome = await db.$transaction(async (transaction) => { outcome = await db.$transaction(async (transaction) => {
if (acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity }); await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
const row = await lockInputEvent(transaction, requestId); const row = await lockInputEvent(transaction, requestId);
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity }); const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
@@ -274,43 +270,48 @@ export const executeInputEvent = async <T>(options: {
throw new DuplicateInputEventError(requestId); throw new DuplicateInputEventError(requestId);
} }
await claimInputEvent(transaction, requestId, payloadIdentity, row); await claimInputEvent(transaction, requestId, payloadIdentity);
const savepointDb = transaction as SavepointDatabaseClient; const savepointDb = transaction as SavepointDatabaseClient;
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
businessStarted = true; businessStarted = true;
try { try {
const value = await execute(transaction); const value = await execute(transaction);
const durableResult = canonicalJsonValue(value); const durableResult = canonicalJsonValue(value);
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { requestId }, UPDATE input_event
data: { SET status = 'SUCCEEDED'::"InputEventStatus",
status: 'SUCCEEDED', result = CAST(${JSON.stringify(durableResult)} AS jsonb),
result: asJson(durableResult), error = NULL,
error: null, completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
completedAt: new Date(), WHERE request_id = ${requestId}
}, `);
});
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
return { kind: 'executed', value }; return { kind: 'executed', value };
} catch (error) { } catch (error) {
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
const message = error instanceof Error ? error.message : 'Unknown API input event error.'; const message = error instanceof Error ? error.message : 'Unknown API input event error.';
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { requestId }, UPDATE input_event
data: { SET status = 'FAILED'::"InputEventStatus",
status: 'FAILED', result = NULL,
result: GamePrisma.DbNull, error = ${message},
error: message, completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
completedAt: new Date(), WHERE request_id = ${requestId}
}, `);
});
return { kind: 'failed', error }; return { kind: 'failed', error };
} }
}); });
} catch (error) { } catch (error) {
if (businessStarted && !(error instanceof DuplicateInputEventError)) { if (businessStarted && !(error instanceof DuplicateInputEventError)) {
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error }); await markUnexpectedFailure(db, {
requestId,
eventType,
actorUserId,
payloadIdentity,
error,
acquireClockFence,
});
} }
throw error; throw error;
} }
+139 -85
View File
@@ -1,9 +1,13 @@
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; import {
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra'; enqueuePrivateMessageWebPush,
GamePrisma,
persistMessageEnvelope,
type MessageGameContext,
} from '@sammo-ts/infra';
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic'; import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js'; import { loadCurrentGameTime } from '../services/gameClock.js';
export interface MessageView { export interface MessageView {
id: number; id: number;
@@ -22,7 +26,9 @@ interface MessageRow {
src: number; src: number;
dest: number; dest: number;
time: Date; time: Date;
valid_until: Date; created_at_wall: Date;
action_status: string | null;
expires_game_tick: bigint | null;
message: unknown; message: unknown;
} }
@@ -48,70 +54,66 @@ const formatMessageTime = (value: Date): string => {
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; )} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
}; };
const messageValidityPredicate = (gameTime: CurrentGameTime) => { const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
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 payload = parsePayload(row.message); 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 { return {
id: row.id, id: row.id,
msgType: row.type, msgType: row.type,
src: payload.src, src: payload.src,
dest: row.type === 'public' ? null : payload.dest, dest: row.type === 'public' ? null : payload.dest,
text: payload.text, text: payload.text,
option: payload.option ?? null, option:
time: formatMessageTime(new Date(row.time)), 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> => { export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
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); const gameTime = await loadCurrentGameTime(db);
const toTickOrNull = (date: Date): bigint | null => { if (
// Ref represents its unlimited 9999-12-31 message lifetime with the gameTime.tick === null ||
// largest safe game tick instead of falling back to a wall-clock-only row. gameTime.revision === null ||
if (date.getUTCFullYear() >= 9000) { gameTime.revision === undefined ||
return BigInt(MAX_SAFE_GAME_TICK); gameTime.deadlineGeneration === null ||
gameTime.deadlineGeneration === undefined
) {
throw new Error(`Actionable message ${action} requires an initialized game clock.`);
} }
try { let expiresGameTick: bigint | null = null;
const tick = gameTime.dateToTick(date); if (draft.validUntil.getUTCFullYear() < 9000) {
return tick === null ? null : BigInt(tick); const expires = gameTime.dateToTick(draft.validUntil);
} catch { if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
return null; expiresGameTick = BigInt(expires);
} }
gameContext = {
occurredGameTick: BigInt(gameTime.tick),
clockRevision: BigInt(gameTime.revision),
deadlineGeneration: BigInt(gameTime.deadlineGeneration),
expiresGameTick,
}; };
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.');
} }
const id = await persistMessageEnvelope(db, draft, gameContext);
await enqueuePrivateMessageWebPush(db, draft, id); await enqueuePrivateMessageWebPush(db, draft, id);
return 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: { export const fetchMessagesFromMailbox = async (params: {
db: DatabaseClient; db: DatabaseClient;
mailbox: number; mailbox: number;
@@ -120,19 +122,20 @@ export const fetchMessagesFromMailbox = async (params: {
fromSeq: number; fromSeq: number;
}): Promise<MessageView[]> => { }): Promise<MessageView[]> => {
const fromSeq = Math.max(params.fromSeq, 0); const fromSeq = Math.max(params.fromSeq, 0);
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>` const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE mailbox = ${params.mailbox} ma.status AS action_status, ma.expires_game_tick
AND type = ${params.msgType} FROM message m
AND ${messageValidityPredicate(gameTime)} LEFT JOIN message_action ma ON ma.message_id = m.id
AND id >= ${fromSeq} WHERE m.mailbox = ${params.mailbox}
ORDER BY id DESC AND m.type = ${params.msgType}
AND m.id >= ${fromSeq}
ORDER BY m.id DESC
LIMIT ${params.limit} LIMIT ${params.limit}
`; `;
return rows.map(toMessageView); return loadMessageViews(params.db, rows);
}; };
export const fetchOldMessagesFromMailbox = async (params: { export const fetchOldMessagesFromMailbox = async (params: {
@@ -142,28 +145,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
toSeq: number; toSeq: number;
limit: number; limit: number;
}): Promise<MessageView[]> => { }): Promise<MessageView[]> => {
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>` const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE mailbox = ${params.mailbox} ma.status AS action_status, ma.expires_game_tick
AND type = ${params.msgType} FROM message m
AND ${messageValidityPredicate(gameTime)} LEFT JOIN message_action ma ON ma.message_id = m.id
AND id < ${params.toSeq} WHERE m.mailbox = ${params.mailbox}
ORDER BY id DESC AND m.type = ${params.msgType}
AND m.id < ${params.toSeq}
ORDER BY m.id DESC
LIMIT ${params.limit} LIMIT ${params.limit}
`; `;
return rows.map(toMessageView); return loadMessageViews(params.db, rows);
}; };
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => { export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db);
const rows = await db.$queryRaw<MessageRow[]>` const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE id = ${id} ma.status AS action_status, ma.expires_game_tick
AND ${messageValidityPredicate(gameTime)} FROM message m
LEFT JOIN message_action ma ON ma.message_id = m.id
WHERE m.id = ${id}
LIMIT 1 LIMIT 1
`; `;
const row = rows[0]; const row = rows[0];
@@ -172,20 +177,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
id: row.id, id: row.id,
mailbox: row.mailbox, mailbox: row.mailbox,
msgType: row.type, msgType: row.type,
time: new Date(row.time), time: new Date(row.created_at_wall ?? row.time),
payload: parsePayload(row.message), payload: parsePayload(row.message),
}; };
}; };
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => { export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db); 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[]>` const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE id = ${id} ma.status AS action_status, ma.expires_game_tick
AND ${messageValidityPredicate(gameTime)} 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 LIMIT 1
FOR UPDATE FOR UPDATE OF m, ma, world
`; `;
const row = rows[0]; const row = rows[0];
if (!row) return null; if (!row) return null;
@@ -193,7 +207,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
id: row.id, id: row.id,
mailbox: row.mailbox, mailbox: row.mailbox,
msgType: row.type, msgType: row.type,
time: new Date(row.time), time: new Date(row.created_at_wall ?? row.time),
payload: parsePayload(row.message), 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))); const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return; if (uniqueIds.length === 0) return;
const gameTime = await loadCurrentGameTime(db); 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({ await db.message.updateMany({
where: { id: { in: uniqueIds } }, where: { id: { in: uniqueIds } },
data: { data: {
validUntil: gameTime.now, validUntil: gameTime.now,
// A partially migrated profile can still carry a legacy logical validUntilTick: BigInt(gameTime.tick),
// 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),
}, },
}); });
}; };
@@ -233,8 +247,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
END END
) || jsonb_build_object('invalid', true), ) || jsonb_build_object('invalid', true),
true true
) ),
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id IN (${GamePrisma.join(uniqueIds)}) 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 = ( export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null }, auction: { closeAt: Date; closeTick: bigint | null },
time: { now: Date; tick: number | null } time: { now: Date; tick: number | null }
): boolean => ): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
auction.closeTick !== null && time.tick !== null
? auction.closeTick < BigInt(time.tick)
: auction.closeAt.getTime() < time.now.getTime();
interface AuctionBidRow { interface AuctionBidRow {
id: number; id: number;
@@ -433,7 +430,6 @@ export const auctionRouter = router({
auctionId: auction.id, auctionId: auction.id,
generalId: general.id, generalId: general.id,
amount: input.amount, amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true, tryExtendCloseDate: true,
}); });
throwIfCommandRejected(result); throwIfCommandRejected(result);
@@ -448,7 +444,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt); const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [ 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 }; return { ok: true };
@@ -511,7 +507,6 @@ export const auctionRouter = router({
auctionId: auction.id, auctionId: auction.id,
generalId: general.id, generalId: general.id,
amount: input.amount, amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true, tryExtendCloseDate: true,
}); });
throwIfCommandRejected(result); throwIfCommandRejected(result);
@@ -526,7 +521,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt); const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [ 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 }; return { ok: true };
@@ -650,7 +645,6 @@ export const auctionRouter = router({
auctionId: auction.id, auctionId: auction.id,
generalId: general.id, generalId: general.id,
amount: input.amount, amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: input.tryExtendCloseDate ?? false, tryExtendCloseDate: input.tryExtendCloseDate ?? false,
}); });
throwIfCommandRejected(result); throwIfCommandRejected(result);
@@ -665,7 +659,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt); const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [ 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 }; return { ok: true };
+40 -5
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server'; 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 { z } from 'zod';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
return world; 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({ export const bettingRouter = router({
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()) getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
.query(async ({ ctx, input }) => { async ({ ctx, input }) => {
requireUserId(ctx.auth); requireUserId(ctx.auth);
await getMyGeneral(ctx); await getMyGeneral(ctx);
const [world, rows] = await Promise.all([ const [world, rows] = await Promise.all([
@@ -66,7 +100,8 @@ export const bettingRouter = router({
year: world.currentYear, year: world.currentYear,
month: world.currentMonth, month: world.currentMonth,
}; };
}), }
),
getDetail: authedProcedure getDetail: authedProcedure
.input(z.object({ bettingId: z.number().int().positive() })) .input(z.object({ bettingId: z.number().int().positive() }))
@@ -141,7 +176,7 @@ export const bettingRouter = router({
if (betting.finished) { if (betting.finished) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' }); 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); const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
if (betting.closeYearMonth <= yearMonth) { if (betting.closeYearMonth <= yearMonth) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' }); 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 type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
import { insertMessage } from '../../messages/store.js'; import { insertMessage } from '../../messages/store.js';
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.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 { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js'; import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -306,8 +306,6 @@ export const diplomacyRouter = router({
nationColor: destNation.color, nationColor: destNation.color,
}, },
}; };
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
const created = await ctx.db.diplomacyLetter.create({ const created = await ctx.db.diplomacyLetter.create({
data: { data: {
srcNationId: srcNation.id, srcNationId: srcNation.id,
@@ -316,7 +314,6 @@ export const diplomacyRouter = router({
state: 'PROPOSED', state: 'PROPOSED',
textBrief: purifyDiplomacyHtml(input.brief), textBrief: purifyDiplomacyHtml(input.brief),
textDetail: purifyDiplomacyHtml(input.detail), textDetail: purifyDiplomacyHtml(input.detail),
date: letterDate,
srcSignerId: me.id, srcSignerId: me.id,
aux: aux as GamePrisma.InputJsonValue, aux: aux as GamePrisma.InputJsonValue,
}, },
@@ -332,7 +329,7 @@ export const diplomacyRouter = router({
src: srcTarget, src: srcTarget,
dest: destTarget, dest: destTarget,
text, text,
time: letterDate, time: created.date,
}); });
return { id: created.id }; return { id: created.id };
@@ -371,7 +368,7 @@ export const diplomacyRouter = router({
); );
const messageSrc = buildActorTarget(me, destNation); const messageSrc = buildActorTarget(me, destNation);
const messageDest = buildNationTarget(srcNation); const messageDest = buildNationTarget(srcNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now; const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux); const aux = asRecord(letter.aux);
let messageText: string; let messageText: string;
if (input.agree) { if (input.agree) {
@@ -458,7 +455,7 @@ export const diplomacyRouter = router({
); );
const messageSrc = buildActorTarget(me, srcNation); const messageSrc = buildActorTarget(me, srcNation);
const messageDest = buildNationTarget(destNation); const messageDest = buildNationTarget(destNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now; const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux); const aux = asRecord(letter.aux);
aux.reason = { aux.reason = {
who: me.id, who: me.id,
@@ -519,7 +516,7 @@ export const diplomacyRouter = router({
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation; const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
const messageSrc = buildActorTarget(me, actorNation); const messageSrc = buildActorTarget(me, actorNation);
const messageDest = buildNationTarget(otherNation); const messageDest = buildNationTarget(otherNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now; const messageTime = await readDatabaseWallTime(ctx.db);
let resultState: 'ACTIVATED' | 'CANCELLED'; let resultState: 'ACTIVATED' | 'CANCELLED';
let messageText: string; let messageText: string;
+29 -17
View File
@@ -4,6 +4,11 @@ import { z } from 'zod';
import type { GameApiContext, WorldStateRow } from '../../context.js'; import type { GameApiContext, WorldStateRow } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common'; import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
} from '@sammo-ts/infra';
import { import {
isWarTraitKey, isWarTraitKey,
JOIN_PERSONALITY_TRAIT_KEYS, JOIN_PERSONALITY_TRAIT_KEYS,
@@ -393,15 +398,12 @@ export const joinRouter = router({
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const gameTime = await loadCurrentGameTime(ctx.db);
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId); const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReserve', type: 'selectPoolReserve',
...(commandRequestId ? { requestId: commandRequestId } : {}), ...(commandRequestId ? { requestId: commandRequestId } : {}),
userId, userId,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId, seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
}); });
return resolveSelectionReservationCommandResult(result); return resolveSelectionReservationCommandResult(result);
}), }),
@@ -431,7 +433,6 @@ export const joinRouter = router({
}); });
} }
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create'); const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate', type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}), ...(commandRequestId ? { requestId: commandRequestId } : {}),
@@ -440,8 +441,6 @@ export const joinRouter = router({
uniqueName: input.uniqueName, uniqueName: input.uniqueName,
personality: input.personality, personality: input.personality,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId, seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
...(selectedIcon ...(selectedIcon
? { ? {
ownerPicture: selectedIcon.picture, ownerPicture: selectedIcon.picture,
@@ -471,15 +470,12 @@ export const joinRouter = router({
input.clientRequestId, input.clientRequestId,
'reselect' 'reselect'
); );
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReselect', type: 'selectPoolReselect',
...(commandRequestId ? { requestId: commandRequestId } : {}), ...(commandRequestId ? { requestId: commandRequestId } : {}),
userId, userId,
ownerDisplayName: auth.user.displayName, ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName, uniqueName: input.uniqueName,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
}); });
return resolveSelectionCommandResult(result, 'selectPoolReselect'); return resolveSelectionCommandResult(result, 'selectPoolReselect');
}), }),
@@ -583,30 +579,46 @@ export const joinRouter = router({
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
}); });
} }
const worldState = await ctx.db.worldState.findFirst(); try {
if (!worldState) { 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({ throw new TRPCError({
code: 'PRECONDITION_FAILED', code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.', message: 'World state is not initialized.',
}); });
} }
try { if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) {
const gameTime = await loadCurrentGameTime(ctx.db); throw new TRPCError({
if (gameTime.tick === null) { code: 'PRECONDITION_FAILED',
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
});
}
const worldState = await transaction.worldState.findFirst();
const gameTime = await loadCurrentGameTime(transaction);
if (!worldState || gameTime.tick === null) {
throw new TRPCError({ throw new TRPCError({
code: 'PRECONDITION_FAILED', code: 'PRECONDITION_FAILED',
message: 'Game clock is not initialized.', message: 'Game clock is not initialized.',
}); });
} }
return await reserveNpcPossessionCandidates({ return reserveNpcPossessionCandidates({
db: ctx.db, db: transaction,
worldState, worldState,
userId: auth.user.id, userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id, ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh, refresh: input.refresh,
keepIds: input.keepIds, keepIds: input.keepIds,
now: gameTime.now, now: gameTime.now,
acceptedGameTick: gameTime.tick, createdGameTick: gameTime.tick,
});
}); });
} catch (error) { } catch (error) {
if (error instanceof NpcPossessionError) { if (error instanceof NpcPossessionError) {
+19 -13
View File
@@ -5,7 +5,13 @@ import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import {
accessLimitAuthedInputProcedure,
accessWallAuthedInputProcedure,
authedProcedure,
router,
wallAuthedProcedure,
} from '../../trpc.js';
import { import {
MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC, MESSAGE_MAILBOX_PUBLIC,
@@ -20,13 +26,12 @@ import {
fetchOldMessagesFromMailbox, fetchOldMessagesFromMailbox,
fetchMessageById, fetchMessageById,
insertMessage, insertMessage,
tombstoneMessages, tombstoneMessagesWithinDeleteWindow,
type MessageView, type MessageView,
} from '../../messages/store.js'; } from '../../messages/store.js';
import { getOwnedGeneral } from '../shared/general.js'; import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js'; import { resolveNationPermission } from '../nation/shared.js';
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js'; import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -231,7 +236,7 @@ export const messagesRouter = router({
})), })),
}; };
}), }),
readLatest: authedProcedure readLatest: wallAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -264,7 +269,7 @@ export const messagesRouter = router({
`; `;
return { ok: true }; return { ok: true };
}), }),
delete: authedProcedure delete: wallAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -289,17 +294,16 @@ export const messagesRouter = router({
if (message.payload.option?.deletable === false) { if (message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' }); 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 receiverMessageId = message.payload.option?.receiverMessageID;
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national'; const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
const ids = [ const ids = [
message.id, message.id,
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), ...(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 = const receiverMailbox =
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private' shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
? message.payload.dest.generalId ? message.payload.dest.generalId
@@ -309,7 +313,7 @@ export const messagesRouter = router({
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId ? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
: null; : null;
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]); markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
return { ok: true, deletedIds: ids }; return { ok: true, deletedIds };
}), }),
respond: authedProcedure respond: authedProcedure
.input( .input(
@@ -420,7 +424,7 @@ export const messagesRouter = router({
...messageBuckets, ...messageBuckets,
}; };
}), }),
send: accessAuthedInputProcedure( send: accessWallAuthedInputProcedure(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
mailbox: z.number().int(), mailbox: z.number().int(),
@@ -436,7 +440,9 @@ export const messagesRouter = router({
} }
const src = await buildTargetFromGeneral(ctx.db, general); 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'); const validUntil = new Date('9999-12-31T00:00:00Z');
let msgType: MessageType; let msgType: MessageType;
+44 -5
View File
@@ -8,10 +8,10 @@ import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js'; import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js'; import { buildTournamentKeys } from '../../tournament/keys.js';
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.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 { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime } from '../../services/gameClock.js';
import { ensureActiveRedisClockFence } from '../../services/redisClockFence.js'; import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
import { loadClockAdminStatus } from '../../services/clockReadiness.js'; import { loadClockAdminStatus } from '../../services/clockReadiness.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => { const hasAdminRole = (roles: string[], profileName: string): boolean => {
@@ -64,7 +64,7 @@ const withTournamentClockMutation = async <T>(
}); });
} }
const clockContext: TournamentClockContext = { const clockContext: TournamentClockContext = {
phase: 'RUNNING', phase: fence.phase,
revision: fence.revision, revision: fence.revision,
deadlineGeneration: fence.generation, deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick, dateToTick: gameTime.dateToTick,
@@ -72,6 +72,37 @@ const withTournamentClockMutation = async <T>(
return store.withClockContext(clockContext, () => store.withMutationLock(operation)); 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({ const zTournamentState = z.object({
stage: z.number().int().min(0), stage: z.number().int().min(0),
phase: z.number().int().min(0), phase: z.number().int().min(0),
@@ -544,7 +575,10 @@ export const tournamentRouter = router({
return { ok: true }; 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( .input(
z.object({ z.object({
targetId: z.number().int().positive(), targetId: z.number().int().positive(),
@@ -554,7 +588,7 @@ export const tournamentRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return withTournamentClockMutation(ctx, store, async () => { return withTournamentBetClockMutation(ctx, store, async () => {
const state = await store.getState(); const state = await store.getState();
if (!state || state.stage !== 6) { if (!state || state.stage !== 6) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
@@ -589,6 +623,7 @@ export const tournamentRouter = router({
const adjustResult = await ctx.turnDaemon.requestCommand({ const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet', reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }], adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
}); });
@@ -604,6 +639,7 @@ export const tournamentRouter = router({
const rankResult = await ctx.turnDaemon.requestCommand({ const rankResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
reason: 'tournamentBet', reason: 'tournamentBet',
adjustments: [ adjustments: [
{ {
@@ -615,6 +651,7 @@ export const tournamentRouter = router({
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) { if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
await ctx.turnDaemon.requestCommand({ await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }], adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}); });
@@ -631,11 +668,13 @@ export const tournamentRouter = router({
await Promise.all([ await Promise.all([
ctx.turnDaemon.requestCommand({ ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }], adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}), }),
ctx.turnDaemon.requestCommand({ ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [ adjustments: [
{ {
+14 -20
View File
@@ -102,18 +102,16 @@ export const hasPollEnded = (
time: CurrentGameTime time: CurrentGameTime
): boolean => ): boolean =>
Boolean(poll.closed_at) || Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null Boolean(
? poll.end_tick < BigInt(time.tick) poll.end_at &&
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime())); (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
);
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => { const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null; if (!date) return null;
try {
const tick = time.dateToTick(date); const tick = time.dateToTick(date);
return tick === null ? null : BigInt(tick); if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
} catch { return BigInt(tick);
return null;
}
}; };
type VoteListRow = { type VoteListRow = {
@@ -358,7 +356,6 @@ export const voteRouter = router({
voteId: input.voteId, voteId: input.voteId,
generalId: general.id, generalId: general.id,
selection: sortedSelection, selection: sortedSelection,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
}); });
throwIfCommandRejected(rewardResult); throwIfCommandRejected(rewardResult);
@@ -399,8 +396,6 @@ export const voteRouter = router({
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } }) ? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
: null; : null;
const nationName = nation?.name ?? '재야'; const nationName = nation?.name ?? '재야';
const createdAt = new Date();
await ctx.db.$queryRaw(GamePrisma.sql` await ctx.db.$queryRaw(GamePrisma.sql`
INSERT INTO vote_comment ( INSERT INTO vote_comment (
vote_id, vote_id,
@@ -418,7 +413,7 @@ export const voteRouter = router({
${general.name}, ${general.name},
${nationName}, ${nationName},
${input.text}, ${input.text},
${createdAt} CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
`); `);
@@ -451,7 +446,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) { if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
} }
const operationalAt = new Date();
let multipleOptions = input.multipleOptions; let multipleOptions = input.multipleOptions;
if (multipleOptions < 0) { if (multipleOptions < 0) {
@@ -464,7 +458,8 @@ export const voteRouter = router({
if (input.closePrevious) { if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql` await ctx.db.$queryRaw(GamePrisma.sql`
UPDATE vote_poll 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 WHERE closed_at IS NULL
`); `);
} }
@@ -497,8 +492,8 @@ export const voteRouter = router({
${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt}, ${endAt},
${toGameTickOrNull(gameTime, endAt)}, ${toGameTickOrNull(gameTime, endAt)},
${operationalAt}, CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
${operationalAt} CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
`); `);
@@ -573,7 +568,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) { if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
} }
const updatedAt = new Date();
if ( if (
input.title === undefined && input.title === undefined &&
@@ -596,7 +590,7 @@ export const voteRouter = router({
reveal_mode = COALESCE(${input.revealMode}, reveal_mode), reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at}, end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick}, end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = ${updatedAt} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId} WHERE id = ${input.voteId}
`); `);
@@ -609,10 +603,10 @@ export const voteRouter = router({
.input(z.object({ voteId: z.number().int().positive() })) .input(z.object({ voteId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const gameTime = await loadCurrentGameTime(ctx.db); const gameTime = await loadCurrentGameTime(ctx.db);
const updatedAt = new Date();
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql` const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
UPDATE vote_poll 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} WHERE id = ${input.voteId}
RETURNING id RETURNING id
`); `);
+33 -4
View File
@@ -1,4 +1,5 @@
import type { CurrentGameTime } from './gameClock.js'; import type { CurrentGameTime } from './gameClock.js';
import type { GameClockPhase } from '@sammo-ts/common';
interface ClockFenceRedis { interface ClockFenceRedis {
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>; eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
@@ -26,30 +27,58 @@ export interface ActiveRedisClockFence {
phaseKey: string; phaseKey: string;
revision: number; revision: number;
generation: number; generation: number;
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
} }
export const ensureActiveRedisClockFence = async ( type MutableProjectionPhase = ActiveRedisClockFence['phase'];
const ensureRedisClockFence = async (
redis: ClockFenceRedis, redis: ClockFenceRedis,
profileName: string, profileName: string,
gameTime: CurrentGameTime gameTime: CurrentGameTime,
allowedPhases: readonly GameClockPhase[]
): Promise<ActiveRedisClockFence | null> => { ): Promise<ActiveRedisClockFence | null> => {
if ( if (
gameTime.phase !== 'RUNNING' || !gameTime.phase ||
!allowedPhases.includes(gameTime.phase) ||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
!Number.isSafeInteger(gameTime.revision) || !Number.isSafeInteger(gameTime.revision) ||
!Number.isSafeInteger(gameTime.deadlineGeneration) !Number.isSafeInteger(gameTime.deadlineGeneration)
) { ) {
return null; return null;
} }
const phase: MutableProjectionPhase = gameTime.phase;
const fence: ActiveRedisClockFence = { const fence: ActiveRedisClockFence = {
activeRevisionKey: `sammo:${profileName}:clock:active-revision`, activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`, deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
phaseKey: `sammo:${profileName}:clock:phase`, phaseKey: `sammo:${profileName}:clock:phase`,
revision: gameTime.revision!, revision: gameTime.revision!,
generation: gameTime.deadlineGeneration!, generation: gameTime.deadlineGeneration!,
phase,
}; };
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, { const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey], keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey],
arguments: [String(fence.revision), String(fence.generation), 'RUNNING'], arguments: [String(fence.revision), String(fence.generation), phase],
}); });
return Number(result) === 1 || Number(result) === 2 ? fence : null; 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 { gatewayProfileCapabilities } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { ProfileStatusSource } from '../auth/profileStatusSource.js'; import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
interface TurnDaemonLeaseSource { interface TurnDaemonLeaseSource {
turnDaemonLease: { $queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
findUnique(input: {
where: { profile: string };
select: { leaseUntil: true };
}): Promise<{ leaseUntil: Date } | null>;
};
} }
export const loadTurnEngineRunning = async ( export const loadTurnEngineRunning = async (
source: ProfileStatusSource | undefined, source: ProfileStatusSource | undefined,
db: TurnDaemonLeaseSource, db: TurnDaemonLeaseSource,
profileName: string, profileName: string,
now = new Date() now?: Date
): Promise<boolean | null> => { ): Promise<boolean | null> => {
if (!source) return null; if (!source) return null;
try { try {
const status = await source.get(profileName); const status = await source.get(profileName);
if (status === null) return null; if (status === null) return null;
if (!gatewayProfileCapabilities(status).turnsRunning) return false; if (!gatewayProfileCapabilities(status).turnsRunning) return false;
const lease = await db.turnDaemonLease.findUnique({ const wallNow = now
where: { profile: profileName }, ? GamePrisma.sql`${now}`
select: { leaseUntil: true }, : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
}); const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
return lease !== null && lease.leaseUntil.getTime() > now.getTime(); SELECT EXISTS (
SELECT 1
FROM turn_daemon_lease
WHERE profile = ${profileName}
AND lease_until > ${wallNow}
) AS running
`);
return rows[0]?.running ?? false;
} catch { } catch {
return null; return null;
} }
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
private readonly db: TurnDaemonLeaseSource, private readonly db: TurnDaemonLeaseSource,
private readonly profileName: string, private readonly profileName: string,
private readonly cacheMs = 2_000, private readonly cacheMs = 2_000,
private readonly now = () => Date.now() private readonly now = () => performance.now()
) {} ) {}
get(): Promise<boolean | null> { 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 { createHmac, randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common'; import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
`); `);
if (rows.length === 0) return []; if (rows.length === 0) return [];
const ids = rows.map((row) => row.id); const ids = rows.map((row) => row.id);
await tx.webPushOutbox.updateMany({ await tx.$executeRaw(GamePrisma.sql`
where: { id: { in: ids } }, UPDATE "web_push_outbox"
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } }, 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({ return tx.webPushOutbox.findMany({
where: { id: { in: ids }, lockOwner: this.owner }, where: { id: { in: ids }, lockOwner: this.owner },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
}); });
for (const event of claimed) { for (const event of claimed) {
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) { const expired = await this.db.$executeRaw(GamePrisma.sql`
await this.db.webPushOutbox.updateMany({ UPDATE "web_push_outbox"
where: { id: event.id, lockOwner: this.owner }, SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null }, "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; continue;
} }
try { try {
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
signal: AbortSignal.timeout(5_000), signal: AbortSignal.timeout(5_000),
}); });
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`); if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
await this.db.webPushOutbox.updateMany({ await this.db.$executeRaw(GamePrisma.sql`
where: { id: event.id, lockOwner: this.owner }, UPDATE "web_push_outbox"
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null }, 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) { } catch (error) {
const attempts = event.attempts; const attempts = event.attempts;
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8)); const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
await this.db.webPushOutbox.updateMany({ const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
where: { id: event.id, lockOwner: this.owner }, await this.db.$executeRaw(GamePrisma.sql`
data: { UPDATE "web_push_outbox"
availableAt: new Date(Date.now() + delaySeconds * 1_000), SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
lockedAt: null, + ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
lockOwner: null, "locked_at" = NULL,
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500), "lock_owner" = NULL,
}, "last_error" = ${errorText}
}); WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
this.onError(error); this.onError(error);
} }
} }
if (Date.now() >= this.nextPruneAt) { if (performance.now() >= this.nextPruneAt) {
this.nextPruneAt = Date.now() + 60_000; this.nextPruneAt = performance.now() + 60_000;
await this.db.$executeRaw(GamePrisma.sql` await this.db.$executeRaw(GamePrisma.sql`
WITH expired AS ( WITH expired AS (
SELECT "id" SELECT "id"
+4 -3
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common'; import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
import { z } from 'zod'; import { z } from 'zod';
@@ -136,7 +137,7 @@ const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T
}; };
export interface TournamentClockContext { export interface TournamentClockContext {
phase: 'RUNNING'; phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
revision: number; revision: number;
deadlineGeneration: number; deadlineGeneration: number;
dateToTick(date: Date): number | null; dateToTick(date: Date): number | null;
@@ -198,8 +199,8 @@ export class TournamentStore {
const lockKey = `${this.keys.stateKey}:mutation-lock`; const lockKey = `${this.keys.stateKey}:mutation-lock`;
const token = randomUUID(); const token = randomUUID();
const deadline = Date.now() + timeoutMs; const deadline = performance.now() + timeoutMs;
while (Date.now() < deadline) { while (performance.now() < deadline) {
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 }); const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) { if (acquired) {
try { try {
+4 -1
View File
@@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => {
const resolveScheduledBaseMs = (state: TournamentState): number => { const resolveScheduledBaseMs = (state: TournamentState): number => {
const scheduled = new Date(state.nextAt).getTime(); 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 => export const resolveNextAt = (state: TournamentState): string =>
+21 -2
View File
@@ -62,7 +62,8 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string => export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`; `${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => { const createInputEventMiddleware = (acquireClockFence: boolean) =>
t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) { if (type !== 'mutation' || !ctx.db.$transaction) {
return next(); return next();
} }
@@ -79,6 +80,7 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex,
eventType: path, eventType: path,
payload, payload,
actorUserId: ctx.auth?.user.id, actorUserId: ctx.auth?.user.id,
acquireClockFence,
execute: async (transaction) => { execute: async (transaction) => {
const result = await next({ const result = await next({
ctx: { ctx: {
@@ -91,7 +93,9 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex,
if (!result.ok) { if (!result.ok) {
throw result.error; throw result.error;
} }
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot())); journalPersisted = Boolean(
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
);
executedResult = result; executedResult = result;
return result.data; return result.data;
}, },
@@ -118,6 +122,9 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex,
} }
}); });
const inputEventMiddleware = createInputEventMiddleware(true);
const wallInputEventMiddleware = createInputEventMiddleware(false);
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => { const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다. // 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
// Router 단위 테스트의 부분 DB mock은 명시적으로 opt-in할 때만 계측한다. // Router 단위 테스트의 부분 DB mock은 명시적으로 opt-in할 때만 계측한다.
@@ -180,10 +187,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
export const router = t.router; export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware); export const procedure = t.procedure.use(inputEventMiddleware);
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
export const authedProcedure: typeof procedure = t.procedure export const authedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
.use(generalActivityMiddleware) .use(generalActivityMiddleware)
.use(inputEventMiddleware); .use(inputEventMiddleware);
export const wallAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware)
.use(wallInputEventMiddleware);
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation // Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다 // transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
@@ -234,6 +246,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
.use(generalAccessEndpointMiddleware) .use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware) .use(generalActivityMiddleware)
.use(inputEventMiddleware); .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) => export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
+5 -8
View File
@@ -96,6 +96,7 @@ const buildContext = (options: {
ok: true as const, ok: true as const,
auctionId: 91, auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z', closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
}; };
} }
return { return {
@@ -103,6 +104,7 @@ const buildContext = (options: {
ok: true as const, ok: true as const,
auctionId: 91, auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z', closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
}; };
}); });
const queryRaw = vi.fn(options.queryRaw ?? (async () => [])); const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
@@ -112,14 +114,10 @@ const buildContext = (options: {
currentYear: 200, currentYear: 200,
currentMonth: 1, currentMonth: 1,
tickSeconds: 3600, tickSeconds: 3600,
...(options.clockTick === undefined
? {}
: {
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'), clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
clockTick: BigInt(options.clockTick), clockTick: BigInt(options.clockTick ?? 100),
clockMode: 'manual', clockMode: 'manual',
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'), clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
}),
config: { config: {
const: { const: {
auctionName: ['청룡', '백호', '주작', '현무'], auctionName: ['청룡', '백호', '주작', '현무'],
@@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => {
tick: 72_000_001, tick: 72_000_001,
}) })
).toBe(true); ).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 () => { it('rejects unauthenticated auction reads', async () => {
@@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31, auctionId: 31,
generalId: 7, generalId: 7,
amount: 110, amount: 110,
acceptedGameTick: 100,
tryExtendCloseDate: false, tryExtendCloseDate: false,
}); });
}); });
@@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => {
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false }, detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN', status: 'OPEN',
closeAt: new Date(Date.now() + 60 * 60_000), closeAt: new Date(Date.now() + 60 * 60_000),
closeTick: 200n,
}, },
]; ];
} }
@@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31, auctionId: 31,
generalId: 7, generalId: 7,
amount: 500, amount: 500,
acceptedGameTick: 100,
tryExtendCloseDate: true, tryExtendCloseDate: true,
}); });
}); });
@@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { amount: 100 }, detail: { amount: 100 },
status, status,
closeAt, closeAt,
closeTick: 0n,
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}), ...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
}, },
}); });
@@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => {
return auction; return auction;
}; };
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
buildAuctionFinalizeRequestId(auction.id, { if (auction.closeTick === null || auction.closeTick === undefined) {
throw new Error(`auction ${auction.id} fixture requires closeTick`);
}
return buildAuctionFinalizeRequestId(auction.id, {
closeAt: auction.closeAt, closeAt: auction.closeAt,
closeTick: auction.closeTick ?? null, closeTick: auction.closeTick,
}); });
};
const memoryRedis = () => ({ const memoryRedis = () => ({
zRangeByScore: vi.fn(async () => []), zRangeByScore: vi.fn(async () => []),
@@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
await expect( await expect(
@@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`); ).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
@@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => {
requestId, requestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', 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', status: 'FAILED',
attempts: 3, attempts: 3,
error: 'simulated terminal failure', error: 'simulated terminal failure',
@@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
await expect( await expect(
@@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
await expect( await expect(
@@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`); ).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
}); });
it('creates a new generation after an earlier close was extended', async () => { it('creates a new generation after an earlier close was extended', async () => {
const auction = await createAuction('OPEN'); 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({ await connector.prisma.inputEvent.create({
data: { data: {
requestId: priorRequestId, requestId: priorRequestId,
@@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -431,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 200, amount: 200,
eventId: `auction-durable-bid:${auction.id}`, eventId: `auction-durable-bid:${auction.id}`,
eventAt: new Date(), eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
}, },
}); });
const requestId = requestIdFor(auction); const requestId = requestIdFor(auction);
@@ -441,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}); });
const world = new InMemoryTurnWorld(state, snapshot, { schedule }); const world = new InMemoryTurnWorld(state, snapshot, { schedule });
@@ -509,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { remainCloseDateExtensionCnt: 1 }, detail: { remainCloseDateExtensionCnt: 1 },
status: 'OPEN', status: 'OPEN',
closeAt: logicalPastCloseAt, closeAt: logicalPastCloseAt,
closeTick: 0n,
}, },
}); });
extensionAuctionId = extensionAuction.id; extensionAuctionId = extensionAuction.id;
@@ -521,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 50, amount: 50,
eventId: `auction-extension-bid:${extensionAuction.id}`, eventId: `auction-extension-bid:${extensionAuction.id}`,
eventAt: new Date(), eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
meta: { tryExtendCloseDate: true }, meta: { tryExtendCloseDate: true },
}, },
}); });
@@ -532,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(extensionAuction.id), id: String(extensionAuction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}); });
let reopened: { status: string; closeAt: Date } | null = null; let reopened: { status: string; closeAt: Date } | null = null;
+30 -14
View File
@@ -33,7 +33,7 @@ const buildDb = (options: {
$executeRaw: vi.fn(async () => options.updated), $executeRaw: vi.fn(async () => options.updated),
auction: { auction: {
findUnique: vi.fn(async () => 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: { inputEvent: {
@@ -233,11 +233,12 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 36_000_000,
}) })
).resolves.toBe('RESCHEDULED'); ).resolves.toBe('RESCHEDULED');
expect(redis.zAdd).toHaveBeenCalledTimes(1); 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(); expect(transaction.inputEvent.create).not.toHaveBeenCalled();
}); });
@@ -267,7 +268,7 @@ describe('auction worker clock-shift race', () => {
it('leaves OPEN untouched and creates one durable command before recording history', async () => { it('leaves OPEN untouched and creates one durable command before recording history', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); 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 { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime(); const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
@@ -279,6 +280,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs, nowMs,
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -293,6 +295,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
}, },
}); });
@@ -324,7 +327,6 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
acceptedGameTick: 72_000_000n,
payload: { payload: {
type: 'auctionFinalize', type: 'auctionFinalize',
requestId, requestId,
@@ -351,6 +353,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -363,7 +366,7 @@ describe('auction worker clock-shift race', () => {
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => { it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); 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({ const { db, transaction } = buildDb({
updated: 0, updated: 0,
auction: { status: 'OPEN', closeAt }, auction: { status: 'OPEN', closeAt },
@@ -377,6 +380,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
status: 'PENDING', status: 'PENDING',
result: null, result: null,
@@ -392,6 +396,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).not.toHaveBeenCalled(); expect(transaction.inputEvent.create).not.toHaveBeenCalled();
@@ -412,6 +417,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: logicalNowMs, nowMs: logicalNowMs,
nowTick: 72_000_000,
historyNowMs: operationalNowMs, historyNowMs: operationalNowMs,
}); });
@@ -421,12 +427,12 @@ describe('auction worker clock-shift race', () => {
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => { it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); 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 = { const existingEvent = {
requestId, requestId,
target: 'ENGINE' as const, target: 'ENGINE' as const,
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 }, payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'PENDING' as const, status: 'PENDING' as const,
result: null, result: null,
}; };
@@ -444,6 +450,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -456,7 +463,7 @@ describe('auction worker clock-shift race', () => {
it('creates one bounded successor after a terminal event failure', async () => { it('creates one bounded successor after a terminal event failure', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); 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 retryRequestId = `${requestId}:retry:1`;
const { db, transaction } = buildDb({ const { db, transaction } = buildDb({
updated: 0, updated: 0,
@@ -466,7 +473,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 }, payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'FAILED', status: 'FAILED',
result: null, result: null,
}, },
@@ -481,6 +488,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -494,6 +502,7 @@ describe('auction worker clock-shift race', () => {
requestId: retryRequestId, requestId: retryRequestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
}, },
}); });
@@ -501,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 () => { it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
const redis = buildRedis(); const redis = buildRedis();
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
const closeAt = new Date('2026-07-30T11:30:00.000Z'); const closeAt = new Date('2026-07-30T11:30:00.000Z');
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`; const previousRequestId = 'auction:finalize:7:tick:36000000';
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({ const { db, transaction } = buildDb({
updated: 0, updated: 0,
auction: { status: 'OPEN', closeAt }, auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
existingEvents: [ existingEvents: [
{ {
requestId: previousRequestId, requestId: previousRequestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 }, payload: {
type: 'auctionFinalize',
requestId: previousRequestId,
auctionId: 7,
expectedCloseTick: 36_000_000,
},
status: 'SUCCEEDED', status: 'SUCCEEDED',
result: { type: 'auctionFinalize', ok: false, auctionId: 7 }, result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
}, },
@@ -528,6 +541,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -541,6 +555,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
}, },
}); });
@@ -560,6 +575,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).rejects.toThrow('event insert failed'); ).rejects.toThrow('event insert failed');
+1 -10
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'; 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 { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra'; import type { RedisConnector } from '@sammo-ts/infra';
@@ -88,7 +87,7 @@ const storedLetter = {
}; };
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = 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; let messageId = 100;
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]); const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
const db = { const db = {
@@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => {
textBrief: '<p><strong>공개</strong></p>', textBrief: '<p><strong>공개</strong></p>',
textDetail: textDetail:
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>', '<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).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 () => { it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
@@ -326,18 +326,14 @@ describe('general access tracking', () => {
: [{ id: 41 }]; : [{ id: 41 }];
}), }),
$executeRaw: vi.fn(async (query: unknown) => { $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'); events.push('input-event-create');
} }
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
return 1; return 1;
}), }),
$executeRawUnsafe: vi.fn(async () => 0), $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 = { const db = {
general: { 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 = { const persistedPayload = {
type: 'voteReward' as const, type: 'voteReward' as const,
requestId: 'vote-reward', requestId: 'vote-reward',
@@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => {
selection: [0], selection: [0],
acceptedGameTick: 100, acceptedGameTick: 100,
}; };
const currentCommand = {
type: 'voteReward' as const,
requestId: 'vote-reward',
userId: 'user-7',
voteId: 1,
generalId: 7,
selection: [0],
};
const create = async () => { const create = async () => {
throw Object.assign(new Error('duplicate'), { code: 'P2002' }); throw Object.assign(new Error('duplicate'), { code: 'P2002' });
}; };
@@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => {
); );
await expect( await expect(
transport.sendCommand({ transport.sendCommand(currentCommand)
...persistedPayload,
acceptedGameTick: 101,
})
).resolves.toBe('vote-reward'); ).resolves.toBe('vote-reward');
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) { for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
await expect( await expect(
transport.sendCommand({ transport.sendCommand({
...persistedPayload, ...currentCommand,
...changedIdentity, ...changedIdentity,
acceptedGameTick: 101,
}) })
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError); ).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
} }
@@ -528,10 +528,6 @@ integration('API input event boundary', () => {
it('reuses the same engine child event but rejects a changed retry payload', async () => { it('reuses the same engine child event but rejects a changed retry payload', async () => {
const transport = new DatabaseTurnDaemonTransport(db, 100); const transport = new DatabaseTurnDaemonTransport(db, 100);
const requestId = 'integration:api:engine-child'; const requestId = 'integration:api:engine-child';
const worldClock = await db.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockRevision: true, deadlineGeneration: true },
});
const acceptedWindowStart = Date.now(); const acceptedWindowStart = Date.now();
await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }); await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 });
const acceptedWindowEnd = Date.now(); const acceptedWindowEnd = Date.now();
@@ -539,9 +535,10 @@ integration('API input event boundary', () => {
expect(event.actorUserId).toBe('user-7'); expect(event.actorUserId).toBe('user-7');
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart); expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd); expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
expect(event.acceptedGameTick).not.toBeNull(); expect(event.acceptedGameTick).toBeNull();
expect(event.acceptedClockRevision).toBe(worldClock?.clockRevision ?? null); expect(event.acceptedClockRevision).toBeNull();
expect(event.acceptedDeadlineGeneration).toBe(worldClock?.deadlineGeneration ?? null); expect(event.acceptedDeadlineGeneration).toBeNull();
expect(event.processingGameTick).toBeNull();
await expect( await expect(
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }) transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
).resolves.toBe(requestId); ).resolves.toBe(requestId);
+41 -2
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.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({ const testRouter = router({
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => { 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 createContext = (payload: unknown = {}) => {
const order: string[] = []; const order: string[] = [];
const queryRaw = vi.fn(async (query: { sql?: string }) => { const queryRaw = vi.fn(async (query: { sql?: string }) => {
@@ -40,7 +48,18 @@ const createContext = (payload: unknown = {}) => {
const transaction = { const transaction = {
$queryRaw: queryRaw, $queryRaw: queryRaw,
$executeRaw: vi.fn(async (query: { sql?: string }) => { $executeRaw: vi.fn(async (query: { sql?: string }) => {
order.push(query.sql?.includes('pg_advisory_xact_lock') ? 'clock-fence' : 'accepted'); 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; return 1;
}), }),
$executeRawUnsafe: vi.fn(async (statement: string) => { $executeRawUnsafe: vi.fn(async (statement: string) => {
@@ -131,4 +150,24 @@ describe('API input-event change journal boundary', () => {
expect(fixture.redisPublish).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).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: { turnDaemonLease: {
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })), findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
}, },
$queryRaw: vi.fn(async () => [{ running: true }]),
} as unknown as DatabaseClient, } as unknown as DatabaseClient,
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) }, profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
}) as unknown as GameApiContext; }) as unknown as GameApiContext;
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, 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 databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
@@ -70,6 +70,7 @@ integration('message deletion tombstone persistence', () => {
expect(rows).toHaveLength(2); expect(rows).toHaveLength(2);
for (const row of rows) { for (const row of rows) {
expect(row.validUntil).toEqual(validUntil); expect(row.validUntil).toEqual(validUntil);
expect(row.tombstonedAtWall).not.toBeNull();
expect(row.message).toMatchObject({ expect(row.message).toMatchObject({
text: '삭제된 메시지입니다.', text: '삭제된 메시지입니다.',
option: { invalid: true }, option: { invalid: true },
@@ -81,4 +82,43 @@ integration('message deletion tombstone persistence', () => {
}) })
).rejects.toBe(rollback); ).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);
});
}); });
+109 -11
View File
@@ -210,7 +210,7 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result.msgType).toBe('national'); 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 () => { 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(); 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 () => { it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
const ambassador = { const ambassador = {
...general, ...general,
@@ -259,7 +337,7 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result.msgType).toBe('diplomacy'); 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 () => { 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(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
expect(queryRaw).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([ expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 }, { domain: 'messages.mailbox', entityId: 9000 },
@@ -314,7 +391,6 @@ describe('messages router missing-flow compatibility', () => {
expect(result.msgType).toBe('national'); expect(result.msgType).toBe('national');
expect(queryRaw).toHaveBeenCalledTimes(1); 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 () => { it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
@@ -555,7 +631,11 @@ describe('messages router missing-flow compatibility', () => {
}); });
it('invalidates a recent owned message and its receiver copy', async () => { it('invalidates a recent owned message and its receiver copy', async () => {
const queryRaw = vi.fn(async () => [ let rawCall = 0;
const queryRaw = vi.fn(async () => {
rawCall += 1;
if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
return [
{ {
id: 21, id: 21,
mailbox: general.id, mailbox: general.id,
@@ -585,14 +665,16 @@ describe('messages router missing-flow compatibility', () => {
option: { receiverMessageID: 22 }, option: { receiverMessageID: 22 },
}, },
}, },
]); ];
});
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 }); const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]); expect(result.deletedIds).toEqual([21, 22]);
expect(executeRaw).toHaveBeenCalledOnce(); expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled(); expect(updateMany).not.toHaveBeenCalled();
expect(changeJournal.snapshot()).toEqual([ expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 }, { domain: 'messages.mailbox', entityId: 7 },
@@ -601,7 +683,11 @@ describe('messages router missing-flow compatibility', () => {
}); });
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => { it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
const queryRaw = vi.fn(async () => [ let rawCall = 0;
const queryRaw = vi.fn(async () => {
rawCall += 1;
if (rawCall > 1) return [{ id: 25 }];
return [
{ {
id: 25, id: 25,
mailbox: 9001, mailbox: 9001,
@@ -631,13 +717,15 @@ describe('messages router missing-flow compatibility', () => {
option: { receiverMessageID: 26 }, option: { receiverMessageID: 26 },
}, },
}, },
]); ];
});
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }); const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 }); const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]); expect(result.deletedIds).toEqual([25]);
expect(executeRaw).toHaveBeenCalledOnce(); expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled(); expect(updateMany).not.toHaveBeenCalled();
}); });
@@ -864,6 +952,7 @@ describe('messages router missing-flow compatibility', () => {
const nationUpdate = vi.fn(async () => ({})); const nationUpdate = vi.fn(async () => ({}));
const logCreateMany = vi.fn(async () => ({ count: 1 })); const logCreateMany = vi.fn(async () => ({ count: 1 }));
const messageUpdateMany = 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 cityUpdate = vi.fn(async () => ({}));
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
const { caller } = buildContext( const { caller } = buildContext(
@@ -941,10 +1030,19 @@ describe('messages router missing-flow compatibility', () => {
currentYear: 200, currentYear: 200,
currentMonth: 3, currentMonth: 3,
config: { environment: { mapName: 'che' } }, 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 }, logEntry: { createMany: logCreateMany },
message: { updateMany: messageUpdateMany }, message: { updateMany: messageUpdateMany },
messageAction: { updateMany: messageActionUpdateMany },
$queryRaw: queryRaw, $queryRaw: queryRaw,
}, },
{ changeJournal } { changeJournal }
@@ -987,7 +1085,7 @@ describe('messages router missing-flow compatibility', () => {
); );
expect(setup.messageUpdateMany).toHaveBeenCalledWith({ expect(setup.messageUpdateMany).toHaveBeenCalledWith({
where: { id: { in: [31] } }, where: { id: { in: [31] } },
data: { validUntil: expect.any(Date), validUntilTick: 0n }, data: { validUntil: expect.any(Date), validUntilTick: 1_000n },
}); });
expect(setup.queryRaw).toHaveBeenCalledTimes(9); expect(setup.queryRaw).toHaveBeenCalledTimes(9);
}); });
@@ -13,13 +13,16 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
const bettingId = 990_071; const bettingId = 990_071;
const concurrentBettingId = 990_072; const concurrentBettingId = 990_072;
const phaseBettingId = 990_073;
const generalId = 9_971; const generalId = 9_971;
const otherGeneralId = 9_972; const otherGeneralId = 9_972;
const phaseGeneralId = 9_973;
const nationId = 990_071; const nationId = 990_071;
const otherNationId = 990_072; const otherNationId = 990_072;
const userId = 'nation-betting-router-user'; const userId = 'nation-betting-router-user';
const otherUserId = 'nation-betting-router-other-user'; const otherUserId = 'nation-betting-router-other-user';
const noGeneralUserId = 'nation-betting-router-no-general-user'; const noGeneralUserId = 'nation-betting-router-no-general-user';
const phaseUserId = 'nation-betting-router-phase-user';
const auth: GameSessionTokenPayload = { const auth: GameSessionTokenPayload = {
version: 1, 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', () => { integration('nation betting router', () => {
let db: GamePrismaClient; let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined; let closeDb: (() => Promise<void>) | undefined;
@@ -90,12 +104,14 @@ integration('nation betting router', () => {
await connector.connect(); await connector.connect();
db = connector.prisma; db = connector.prisma;
closeDb = () => connector.disconnect(); closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.inputEvent.deleteMany({
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); 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.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.nation.createMany({ await db.nation.createMany({
@@ -138,6 +154,17 @@ integration('nation betting router', () => {
turnTime: new Date('0200-01-01T00:00:00.000Z'), turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {}, 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({ 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({ await db.nationBetting.create({
data: { data: {
id: concurrentBettingId, id: concurrentBettingId,
@@ -184,17 +222,20 @@ integration('nation betting router', () => {
data: [ data: [
{ userId, key: 'previous', value: 1_000 }, { userId, key: 'previous', value: 1_000 },
{ userId: otherUserId, key: 'previous', value: 500 }, { userId: otherUserId, key: 'previous', value: 500 },
{ userId: phaseUserId, key: 'previous', value: 500 },
], ],
}); });
}); });
afterAll(async () => { afterAll(async () => {
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.inputEvent.deleteMany({
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); 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.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.worldState.delete({ where: { id: worldStateId } }); await db.worldState.delete({ where: { id: worldStateId } });
await closeDb?.(); await closeDb?.();
@@ -290,6 +331,51 @@ integration('nation betting router', () => {
).toMatchObject({ value: 250 }); ).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 () => { it('requires authentication and an owned player general for every betting operation', async () => {
await expect( await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({ 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); }, 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 const reservation = await appRouter
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth)) .createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
.join.listPossessCandidates({}); .join.listPossessCandidates({});
@@ -440,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
).rejects.toMatchObject({ code: 'TIMEOUT' }); ).rejects.toMatchObject({ code: 'TIMEOUT' });
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
const acceptedGameAt = new Date( expect(event.acceptedGameTick).toBeNull();
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time' expect(event.processingGameTick).toBeNull();
); expect(event.payload).not.toHaveProperty('acceptedGameAt');
expect(acceptedGameAt.toString()).not.toBe('Invalid Date'); const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!;
await db.npcSelectionToken.update({ await db.npcSelectionToken.update({
where: { ownerUserId: delayedUserId }, where: { ownerUserId: delayedUserId },
data: { validUntil: acceptedGameAt }, data: { validUntilTick: queuedAtTick },
}); });
await db.worldState.updateMany({ await db.worldState.updateMany({
data: { clockTick: { increment: 1 } }, 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 startRuntime('npc-possession-delayed-retry-daemon');
await expect( await expect(
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input) 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({ await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED', status: 'SUCCEEDED',
attempts: 1, 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); }, 45_000);
it('serializes durable enqueue before a token refresh can replace its nonce', async () => { 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 createFixture = (rows: readonly object[]) => {
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]); 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 incr = vi.fn().mockResolvedValue(41);
const publish = vi.fn().mockResolvedValue(1); const publish = vi.fn().mockResolvedValue(1);
const db = { const db = {
$queryRaw: queryRaw, $queryRaw: queryRaw,
readModelOutbox: { updateMany }, $executeRaw: executeRaw,
readModelOutbox: {},
} as unknown as ReadModelOutboxDatabase; } as unknown as ReadModelOutboxDatabase;
const redis = { incr, publish } as unknown as RedisConnector['client']; 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', () => { describe('ReadModelOutboxWorker', () => {
@@ -47,7 +48,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({ expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
@@ -64,7 +65,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision'); expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
@@ -74,9 +75,7 @@ describe('ReadModelOutboxWorker', () => {
revision: 41, revision: 41,
changes: { frontStatusActorIds: [7] }, changes: { frontStatusActorIds: [7] },
}); });
expect(fixture.updateMany).toHaveBeenCalledWith( expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"');
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
);
}); });
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)( it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
@@ -89,7 +88,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled(); expect(fixture.incr).not.toHaveBeenCalled();
@@ -105,7 +104,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled(); expect(fixture.incr).not.toHaveBeenCalled();
@@ -150,22 +149,15 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(onError).toHaveBeenCalledWith( expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' }) expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
); );
expect(fixture.updateMany).toHaveBeenCalledWith( const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect.objectContaining({ expect(releaseQuery.sql).toContain('"available_at"');
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null }, expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable'));
data: expect.objectContaining({
lockedAt: null,
lockOwner: null,
lastError: expect.stringContaining('redis unavailable'),
}),
})
);
}); });
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => { 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' }); ).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 transport = new InMemoryTurnDaemonTransport();
const requestId = 'select-pool-reserve-http'; const requestId = 'select-pool-reserve-http';
const commandRequestId = `select-pool:user-1:${requestId}:reserve`; const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
@@ -741,8 +741,6 @@ describe('appRouter', () => {
requestId: commandRequestId, requestId: commandRequestId,
userId: 'user-1', userId: 'user-1',
seedOwnerIdentity: '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)) .listGeneralPoolCandidates(new Date(firstReservation.validUntil))
?.some((candidate) => reservedNames.has(candidate.uniqueName)) ?.some((candidate) => reservedNames.has(candidate.uniqueName))
).toBe(false); ).toBe(false);
await expect( const reserveEvent = await db.inputEvent.findUniqueOrThrow({
db.inputEvent.findUniqueOrThrow({
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` }, where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
}) });
).resolves.toMatchObject({ expect(reserveEvent).toMatchObject({
eventType: 'selectPoolReserve', eventType: 'selectPoolReserve',
status: 'SUCCEEDED', status: 'SUCCEEDED',
actorUserId: userId, actorUserId: userId,
payload: { processingGameTick: expect.anything(),
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
}); });
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 createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
const attempts = await Promise.allSettled([ const attempts = await Promise.allSettled([
@@ -357,6 +355,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' }); ).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
const cooledAt = '2026-07-29T00:00:00.000Z'; const cooledAt = '2026-07-29T00:00:00.000Z';
const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await expect( await expect(
turnDaemon.requestCommand({ turnDaemon.requestCommand({
type: 'patchGeneral', type: 'patchGeneral',
@@ -366,6 +365,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: { meta: {
next_change: cooledAt, next_change: cooledAt,
nextChangeAt: 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')) .createCaller(buildContext('select-pool-reselect'))
.join.reselectPoolGeneral({ uniqueName: target.uniqueName }) .join.reselectPoolGeneral({ uniqueName: target.uniqueName })
).resolves.toEqual({ ok: true, generalId: initial.id }); ).resolves.toEqual({ ok: true, generalId: initial.id });
await expect( const reselectionEvent = await db.inputEvent.findUniqueOrThrow({
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } }) where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' },
).resolves.toMatchObject({ });
expect(reselectionEvent).toMatchObject({
eventType: 'selectPoolReselect', eventType: 'selectPoolReselect',
actorUserId: userId, actorUserId: userId,
payload: { processingGameTick: expect.anything(),
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
}); });
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick');
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } }); const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
expect(updated).toMatchObject({ 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 }, data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
}); });
const secondCooledAt = '2026-07-28T00:00:00.000Z'; const secondCooledAt = '2026-07-28T00:00:00.000Z';
const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await turnDaemon.requestCommand({ await turnDaemon.requestCommand({
type: 'patchGeneral', type: 'patchGeneral',
requestId: 'select-pool-full-cooldown-patch', requestId: 'select-pool-full-cooldown-patch',
@@ -463,6 +464,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: { meta: {
next_change: secondCooledAt, next_change: secondCooledAt,
nextChangeAt: secondCooledAt, nextChangeAt: secondCooledAt,
next_change_tick: secondCooledTick,
}, },
}, },
}); });
@@ -571,21 +573,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.join.selectPoolGeneral(stableInput); .join.selectPoolGeneral(stableInput);
expect(retried).toEqual(first); expect(retried).toEqual(first);
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
await expect( const stableEvent = await db.inputEvent.findUniqueOrThrow({
db.inputEvent.findUniqueOrThrow({
where: { where: {
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`, requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
}, },
}) });
).resolves.toMatchObject({ expect(stableEvent).toMatchObject({
status: 'SUCCEEDED', status: 'SUCCEEDED',
attempts: 1, attempts: 1,
actorUserId: otherUserId, actorUserId: otherUserId,
payload: { processingGameTick: expect.anything(),
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
}); });
expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick');
}, 30_000); }, 30_000);
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => { it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
+82 -2
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 { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
@@ -151,6 +151,8 @@ const buildContext = (options: {
develCost?: number; develCost?: number;
currentDevelCost?: number; currentDevelCost?: number;
rankRows?: Array<{ generalId: number; type: string; value: number }>; rankRows?: Array<{ generalId: number; type: string; value: number }>;
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
requestId?: string;
}): GameApiContext => { }): GameApiContext => {
const db = { const db = {
general: { general: {
@@ -168,7 +170,7 @@ const buildContext = (options: {
clockTick: 0n, clockTick: 0n,
clockMode: 'realtime', clockMode: 'realtime',
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'), clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
clockPhase: 'RUNNING', clockPhase: options.clockPhase ?? 'RUNNING',
clockRevision: 1n, clockRevision: 1n,
deadlineGeneration: 1n, deadlineGeneration: 1n,
tickSeconds: 60, tickSeconds: 60,
@@ -178,6 +180,7 @@ const buildContext = (options: {
}, },
} as unknown as DatabaseClient; } as unknown as DatabaseClient;
return { return {
requestId: options.requestId,
db, db,
redis: options.redis as unknown as RedisConnector['client'], redis: options.redis as unknown as RedisConnector['client'],
turnDaemon: options.transport, turnDaemon: options.transport,
@@ -369,6 +372,83 @@ describe('tournament router permissions and mutations', () => {
expect(transport.gold.get(general.id)).toBe(2_400); 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 () => { it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const transport = new TournamentTransport(); const transport = new TournamentTransport();
+7 -13
View File
@@ -5,10 +5,8 @@ import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/t
describe('turn engine status projection', () => { describe('turn engine status projection', () => {
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => { it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
const activeLease = { const activeLease = {
turnDaemonLease: { $queryRaw: vi.fn(async () => [{ running: true }]),
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })), } as any;
},
};
const now = new Date('2026-08-24T00:00:00.000Z'); const now = new Date('2026-08-24T00:00:00.000Z');
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe( await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
true true
@@ -36,7 +34,7 @@ describe('turn engine status projection', () => {
await expect( await expect(
loadTurnEngineRunning( loadTurnEngineRunning(
source, source,
{ turnDaemonLease: { findUnique: async () => null } }, { $queryRaw: async () => [{ running: false }] } as any,
'che:default', 'che:default',
now now
) )
@@ -44,11 +42,7 @@ describe('turn engine status projection', () => {
await expect( await expect(
loadTurnEngineRunning( loadTurnEngineRunning(
source, source,
{ { $queryRaw: async () => [{ running: false }] } as any,
turnDaemonLease: {
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
},
},
'che:default', 'che:default',
now now
) )
@@ -58,10 +52,10 @@ describe('turn engine status projection', () => {
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => { it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
let now = 1_000; let now = 1_000;
const get = vi.fn(async () => 'RUNNING' as const); 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( const cache = new CachedTurnEngineStatus(
{ get }, { get },
{ turnDaemonLease: { findUnique } }, { $queryRaw: queryRaw } as any,
'che:default', 'che:default',
2_000, 2_000,
() => now () => now
@@ -75,6 +69,6 @@ describe('turn engine status projection', () => {
now += 1; now += 1;
await expect(cache.get()).resolves.toBe(true); await expect(cache.get()).resolves.toBe(true);
expect(get).toHaveBeenCalledTimes(2); 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: 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: 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, tick: null })).toBe(true);
expect(
hasPollEnded(
{ closed_at: null, end_at: now, end_tick: null },
{ ...time, now: new Date(now.getTime() + 1), tick: null }
)
).toBe(true);
}); });
it('rejects unauthenticated survey access', async () => { it('rejects unauthenticated survey access', async () => {
@@ -261,7 +255,6 @@ describe('vote router actor and permission boundaries', () => {
voteId: 1, voteId: 1,
generalId: 7, generalId: 7,
selection: [0], selection: [0],
acceptedGameTick: 100,
}); });
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe( expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false false
@@ -301,8 +294,6 @@ describe('vote router actor and permission boundaries', () => {
const auth = buildAuth(['admin.survey.open']); const auth = buildAuth(['admin.survey.open']);
const fixture = buildContext({ auth }); const fixture = buildContext({ auth });
const caller = appRouter.createCaller(fixture.context); 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.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
await expect( await expect(
caller.vote.createPoll({ caller.vote.createPoll({
@@ -314,8 +305,6 @@ describe('vote router actor and permission boundaries', () => {
).resolves.toEqual({ ok: true }); ).resolves.toEqual({ ok: true });
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).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 }); await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
const windowEnd = Date.now();
const mutationQueries = fixture.queryRaw.mock.calls const mutationQueries = fixture.queryRaw.mock.calls
.map(([query]) => query) .map(([query]) => query)
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(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 closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE')); const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id')); 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(); expect(query).toBeDefined();
const value = query?.values.at(index); expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
expect(value).toBeInstanceOf(Date);
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
return value as Date;
}; };
expect(sqlText(commentInsert!)).toContain('created_at'); expect(sqlText(commentInsert!)).toContain('created_at');
expectCurrentDateAt(commentInsert, -1); expectDbWallClock(commentInsert);
expect(sqlText(pollInsert!)).toContain('created_at'); expect(sqlText(pollInsert!)).toContain('created_at');
expect(sqlText(pollInsert!)).toContain('updated_at'); expect(sqlText(pollInsert!)).toContain('updated_at');
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2); expectDbWallClock(pollInsert);
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
expect(pollUpdatedAt).toBe(pollCreatedAt);
expect(pollUpdates).toHaveLength(3); expect(pollUpdates).toHaveLength(3);
expect(sqlText(closePreviousUpdate!)).toContain('updated_at'); expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt); expectDbWallClock(closePreviousUpdate);
expect(sqlText(editPollUpdate!)).toContain('updated_at'); expect(sqlText(editPollUpdate!)).toContain('updated_at');
expectCurrentDateAt(editPollUpdate, -2); expectDbWallClock(editPollUpdate);
expect(sqlText(closePollUpdate!)).toContain('updated_at'); 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 () => { it('reports the current world develcost as the legacy five-times survey reward', async () => {
+45 -28
View File
@@ -60,27 +60,27 @@ export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null }, auction: { closeAt: Date; closeTick: bigint | null },
now: Date, now: Date,
nowTick: number | null nowTick: number | null
): boolean => ): boolean => {
auction.closeTick !== null && nowTick !== null void now;
? auction.closeTick < BigInt(nowTick) return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
: auction.closeAt.getTime() < now.getTime(); };
export const resolveAuctionBidTiming = ( export const resolveAuctionBidTiming = (
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>, world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingNow: Date, processingGameTick: number
acceptedGameTick?: number ): { bidAt: Date; bidTick: number } => {
): { bidAt: Date; bidTick: number } => if (!Number.isSafeInteger(processingGameTick)) {
acceptedGameTick === undefined throw new Error('Auction bid requires an authoritative daemon processing game tick.');
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) } }
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick }; return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
};
export const hasAuctionBidClosePassed = ( export const hasAuctionBidClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null }, auction: { closeAt: Date; closeTick: bigint | null },
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>, world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingNow: Date, processingGameTick: number
acceptedGameTick?: number
): boolean => { ): boolean => {
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick); const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
return hasAuctionClosePassed(auction, bidAt, bidTick); return hasAuctionClosePassed(auction, bidAt, bidTick);
}; };
@@ -268,15 +268,15 @@ export const createAuctionBidder = async (options: {
reason: '경매가 종료되었습니다.', reason: '경매가 종료되었습니다.',
}; };
} }
const processingNow = world.getGameNow(new Date());
const convertedProcessingTick = Reflect.get(command, 'processingGameTick'); const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
const { bidAt, bidTick } = resolveAuctionBidTiming( if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
world, throw new Error('auctionBid requires an authoritative daemon processing game tick.');
processingNow, }
typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick) const requestedAtWall = Reflect.get(command, 'requestedAtWall');
? convertedProcessingTick if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
: command.acceptedGameTick throw new Error('auctionBid requires its durable input-event wall occurrence.');
); }
const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick);
if (hasAuctionClosePassed(auction, bidAt, bidTick)) { if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
return { return {
type: 'auctionBid', type: 'auctionBid',
@@ -510,13 +510,24 @@ export const createAuctionBidder = async (options: {
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => { const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
await tx.$executeRaw( await tx.$executeRaw(
GamePrisma.sql` 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 ( VALUES (
${command.auctionId}, ${command.auctionId},
${command.generalId}, ${command.generalId},
${command.amount}, ${command.amount},
${eventId}, ${eventId},
${eventAt}, ${eventAt},
${BigInt(bidTick)},
${requestedAtWall},
${JSON.stringify({ ${JSON.stringify({
tryExtendCloseDate: command.tryExtendCloseDate ?? true, tryExtendCloseDate: command.tryExtendCloseDate ?? true,
...(auction.type === 'UNIQUE_ITEM' ...(auction.type === 'UNIQUE_ITEM'
@@ -536,7 +547,7 @@ export const createAuctionBidder = async (options: {
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
latest_event_id = ${eventId}, latest_event_id = ${eventId},
latest_event_at = ${eventAt}, latest_event_at = ${eventAt},
updated_at = ${eventAt} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${command.auctionId} WHERE id = ${command.auctionId}
AND status = 'OPEN' AND status = 'OPEN'
AND latest_event_id = ${auction.latestEventId} AND latest_event_id = ${auction.latestEventId}
@@ -556,7 +567,7 @@ export const createAuctionBidder = async (options: {
GamePrisma.sql` GamePrisma.sql`
UPDATE inheritance_point UPDATE inheritance_point
SET value = value - ${morePoint}, SET value = value - ${morePoint},
updated_at = ${eventAt} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE user_id = ${userId} WHERE user_id = ${userId}
AND key = 'previous' AND key = 'previous'
AND value >= ${morePoint} AND value >= ${morePoint}
@@ -587,11 +598,16 @@ export const createAuctionBidder = async (options: {
await tx.$executeRaw( await tx.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at) 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) ON CONFLICT (user_id, key)
DO UPDATE SET DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value, value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
` `
); );
await tx.$executeRaw( await tx.$executeRaw(
@@ -711,6 +727,7 @@ export const createAuctionBidder = async (options: {
ok: true, ok: true,
auctionId: command.auctionId, auctionId: command.auctionId,
closeAt: nextCloseAt.toISOString(), closeAt: nextCloseAt.toISOString(),
closeTick: world.dateToGameTick(nextCloseAt),
}; };
}, },
close: async (): Promise<void> => { close: async (): Promise<void> => {
+14 -15
View File
@@ -91,21 +91,17 @@ export const isAuctionFinalizeGenerationCurrent = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>, auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'> command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
): boolean => { ): boolean => {
if (command.expectedCloseTick !== undefined) {
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick); return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
}
if (command.expectedCloseAt !== undefined) {
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
}
return true;
}; };
export const hasAuctionFinalizeDeadlineArrived = ( export const hasAuctionFinalizeDeadlineArrived = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>, auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
now: Date, now: Date,
nowTick: number nowTick: number
): boolean => ): boolean => {
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick); void now;
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
};
export const buildAuctionBidderSystemMessage = (options: { export const buildAuctionBidderSystemMessage = (options: {
bidder: TurnGeneral; bidder: TurnGeneral;
@@ -293,7 +289,11 @@ export const createAuctionFinalizer = async (options: {
return { type: 'auctionFinalize', ok: true, auctionId }; 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 (auction.status === 'OPEN') {
if (!isAuctionFinalizeGenerationCurrent(auction, command)) { if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
return { return {
@@ -303,8 +303,7 @@ export const createAuctionFinalizer = async (options: {
reason: '경매 마감 세대가 변경되었습니다.', reason: '경매 마감 세대가 변경되었습니다.',
}; };
} }
const nowTick = world.dateToGameTick(now); if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
return { return {
type: 'auctionFinalize', type: 'auctionFinalize',
ok: false, ok: false,
@@ -316,8 +315,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql` GamePrisma.sql`
UPDATE auction UPDATE auction
SET status = 'FINALIZING', SET status = 'FINALIZING',
finalizing_at = ${now}, finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = ${now} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId} WHERE id = ${auctionId}
AND status = 'OPEN' AND status = 'OPEN'
` `
@@ -364,8 +363,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql` GamePrisma.sql`
UPDATE auction UPDATE auction
SET status = ${status}, SET status = ${status},
finished_at = ${now}, finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = ${now} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId} WHERE id = ${auctionId}
` `
); );
+20 -4
View File
@@ -94,7 +94,11 @@ const openResourceAuction = async (
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`); 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 turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000); const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
const auction = await db.auction.create({ const auction = await db.auction.create({
@@ -113,7 +117,7 @@ const openResourceAuction = async (
}, },
status: 'OPEN', status: 'OPEN',
closeAt, closeAt,
openTick: BigInt(world.dateToGameTick(now)), openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)), closeTick: BigInt(world.dateToGameTick(closeAt)),
}, },
}); });
@@ -125,6 +129,7 @@ const openResourceAuction = async (
ok: true, ok: true,
auctionId: auction.id, auctionId: auction.id,
closeAt: closeAt.toISOString(), closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
}; };
}; };
@@ -220,8 +225,16 @@ const openUniqueAuction = async (
} }
const state = world.getState(); 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 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 closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
const closeAt = new Date(now.getTime() + closeMinutes * 60_000); const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
const extensionLimitMinutes = Math.max( const extensionLimitMinutes = Math.max(
@@ -253,7 +266,7 @@ const openUniqueAuction = async (
}, },
status: 'OPEN', status: 'OPEN',
closeAt, closeAt,
openTick: BigInt(world.dateToGameTick(now)), openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)), closeTick: BigInt(world.dateToGameTick(closeAt)),
latestEventId: eventId, latestEventId: eventId,
latestEventAt: now, latestEventAt: now,
@@ -263,6 +276,8 @@ const openUniqueAuction = async (
amount: command.amount, amount: command.amount,
eventId, eventId,
eventAt: now, eventAt: now,
occurredGameTick: BigInt(processingGameTick),
requestedAtWall,
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount), meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
}, },
}, },
@@ -308,6 +323,7 @@ const openUniqueAuction = async (
ok: true, ok: true,
auctionId: auction.id, auctionId: auction.id,
closeAt: closeAt.toISOString(), closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
}; };
}; };
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; 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 { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
import type { import type {
@@ -10,8 +11,9 @@ import type {
TurnDaemonStatus, TurnDaemonStatus,
} from './types.js'; } 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 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 { export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
private readonly localQueue: TurnDaemonCommand[] = []; private readonly localQueue: TurnDaemonCommand[] = [];
@@ -35,8 +37,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
return local.concat(remote); return local.concat(remote);
} }
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> { async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
while (deadlineMs === null || Date.now() < deadlineMs) { const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs);
while (deadline === null || performance.now() < deadline) {
const local = this.localQueue.shift(); const local = this.localQueue.shift();
if (local) { if (local) {
return local; return local;
@@ -45,7 +48,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
if (remote[0]) { if (remote[0]) {
return 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); await delay(remaining);
} }
return null; return null;
@@ -84,35 +87,37 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
return; return;
} }
const terminal = event.attempts >= this.maxAttempts; const terminal = event.attempts >= this.maxAttempts;
await transaction.inputEvent.updateMany({ await transaction.$executeRaw(GamePrisma.sql`
where: { UPDATE input_event
requestId, SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus",
target: 'ENGINE', processing_at = NULL,
status: 'PROCESSING', locked_by = NULL,
lockedBy: this.workerId, lease_until = NULL,
attempts: event.attempts, completed_at = CASE
}, WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
data: { ELSE NULL
status: terminal ? 'FAILED' : 'PENDING', END,
processingAt: null, result = NULL,
lockedBy: null, error = ${message}
leaseUntil: null, WHERE request_id = ${requestId}
completedAt: terminal ? new Date() : null, AND target = 'ENGINE'::"InputEventTarget"
result: GamePrisma.DbNull, AND status = 'PROCESSING'::"InputEventStatus"
error: message, AND locked_by = ${this.workerId}
}, AND attempts = ${event.attempts}
}); `);
}); });
} }
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> { private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
await this.recoverExpiredLeases(); await this.recoverExpiredLeases();
return this.db.$transaction(async (transaction) => { return this.db.$transaction(async (transaction) => {
const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({ const world = await transaction.worldState.findFirst({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true }, select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
}); });
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL'; const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
const currentRevision = world?.clockRevision ?? null; const currentRevision = world?.clockRevision ?? null;
const rows = await transaction.$queryRaw< const rows = await transaction.$queryRaw<
Array<{ Array<{
@@ -141,6 +146,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
AND ( AND (
${gameplayAllowed} ${gameplayAllowed}
OR "event_type" = 'getStatus' OR "event_type" = 'getStatus'
OR (
${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
)
OR ( OR (
${world?.clockPhase === 'SUSPENDED'} ${world?.clockPhase === 'SUSPENDED'}
AND "event_type" = 'messageRespond' AND "event_type" = 'messageRespond'
@@ -148,8 +158,14 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM "message" AS pending_message 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 WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer
AND pending_message."message" #>> '{option,action}' = 'raiseInvader' 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 ( AND EXISTS (
SELECT 1 SELECT 1
@@ -175,9 +191,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
}) })
: []; : [];
const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => { const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => {
if (row.eventType === 'getStatus') return row.acceptedGameTick; if (row.eventType === 'getStatus') return row.acceptedGameTick ?? claimCoordinate.gameTick;
if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) { if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) {
return row.acceptedGameTick ?? world?.clockTick ?? null; return claimCoordinate.gameTick;
} }
if (row.acceptedClockRevision > currentRevision) return undefined; if (row.acceptedClockRevision > currentRevision) return undefined;
let revision = row.acceptedClockRevision; let revision = row.acceptedClockRevision;
@@ -197,19 +213,25 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
entry.processingGameTick !== undefined entry.processingGameTick !== undefined
); );
for (const { row, processingGameTick } of processableRows) { for (const { row, processingGameTick } of processableRows) {
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { sequence: row.sequence }, UPDATE input_event
data: { SET status = 'PROCESSING'::"InputEventStatus",
status: 'PROCESSING', processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
processingAt: new Date(), accepted_game_tick = COALESCE(accepted_game_tick, ${processingGameTick}),
processingGameTick, accepted_clock_revision = COALESCE(accepted_clock_revision, ${currentRevision}),
processingClockRevision: currentRevision, accepted_deadline_generation = COALESCE(
processingDeadlineGeneration: world?.deadlineGeneration ?? null, accepted_deadline_generation,
lockedBy: this.workerId, ${world?.deadlineGeneration ?? null}
leaseUntil: new Date(Date.now() + this.leaseDurationMs), ),
attempts: { increment: 1 }, 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[] = []; const commands: TurnDaemonCommand[] = [];
@@ -220,39 +242,34 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
command: row.payload as TurnDaemonCommand, command: row.payload as TurnDaemonCommand,
}); });
if (!command) { if (!command) {
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { sequence: row.sequence }, UPDATE input_event
data: { SET status = 'FAILED'::"InputEventStatus",
status: 'FAILED', error = ${`Invalid command payload for ${row.eventType}`},
error: `Invalid command payload for ${row.eventType}`, completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
completedAt: new Date(), locked_by = NULL,
lockedBy: null, lease_until = NULL
leaseUntil: null, WHERE sequence = ${row.sequence}
}, `);
});
continue; continue;
} }
if ( if (processingGameTick !== null) {
processingGameTick !== null &&
row.acceptedGameTick !== null &&
processingGameTick !== row.acceptedGameTick
) {
const value = Number(processingGameTick); const value = Number(processingGameTick);
if (!Number.isSafeInteger(value)) { if (!Number.isSafeInteger(value)) {
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { sequence: row.sequence }, UPDATE input_event
data: { SET status = 'FAILED'::"InputEventStatus",
status: 'FAILED', error = 'Converted processing game tick is outside the safe integer range.',
error: 'Converted processing game tick is outside the safe integer range.', completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
completedAt: new Date(), locked_by = NULL,
lockedBy: null, lease_until = NULL
leaseUntil: null, WHERE sequence = ${row.sequence}
}, `);
});
continue; continue;
} }
Reflect.set(command, 'processingGameTick', value); Reflect.set(command, 'processingGameTick', value);
} }
Reflect.set(command, 'requestedAtWall', row.createdAt);
commands.push(command); commands.push(command);
} }
return commands; return commands;
@@ -260,23 +277,20 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
} }
private async complete(requestId: string, result: unknown): Promise<void> { private async complete(requestId: string, result: unknown): Promise<void> {
const completed = await this.db.inputEvent.updateMany({ const completed = await this.db.$executeRaw(GamePrisma.sql`
where: { UPDATE input_event
requestId, SET status = 'SUCCEEDED'::"InputEventStatus",
target: 'ENGINE', result = CAST(${serializeResult(result)} AS jsonb),
status: 'PROCESSING', completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
lockedBy: this.workerId, error = NULL,
}, locked_by = NULL,
data: { lease_until = NULL
status: 'SUCCEEDED', WHERE request_id = ${requestId}
result: asJson(result), AND target = 'ENGINE'::"InputEventTarget"
completedAt: new Date(), AND status = 'PROCESSING'::"InputEventStatus"
error: null, AND locked_by = ${this.workerId}
lockedBy: null, `);
leaseUntil: null, if (completed > 0) {
},
});
if (completed.count > 0) {
return; return;
} }
// Database hooks commit mutation results atomically with game state and // Database hooks commit mutation results atomically with game state and
@@ -297,19 +311,15 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
} }
private async recoverExpiredLeases(): Promise<void> { private async recoverExpiredLeases(): Promise<void> {
const now = new Date(); await this.db.$executeRaw(GamePrisma.sql`
await this.db.inputEvent.updateMany({ UPDATE input_event
where: { SET status = 'PENDING'::"InputEventStatus",
target: 'ENGINE', processing_at = NULL,
status: 'PROCESSING', locked_by = NULL,
leaseUntil: { lt: now }, lease_until = NULL
}, WHERE target = 'ENGINE'::"InputEventTarget"
data: { AND status = 'PROCESSING'::"InputEventStatus"
status: 'PENDING', AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
processingAt: null, `);
lockedBy: null,
leaseUntil: null,
},
});
} }
} }
@@ -1,7 +1,7 @@
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js'; import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
type Waiter = { type Waiter = {
deadlineMs: number | null; timeoutMs: number | null;
resolve: (command: TurnDaemonCommand | null) => void; resolve: (command: TurnDaemonCommand | null) => void;
timeoutId?: ReturnType<typeof setTimeout>; timeoutId?: ReturnType<typeof setTimeout>;
}; };
@@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue {
return drained; return drained;
} }
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> { async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
if (this.queue.length > 0) { if (this.queue.length > 0) {
return this.queue.shift() ?? null; return this.queue.shift() ?? null;
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const waiter: Waiter = { deadlineMs, resolve }; const waiter: Waiter = { timeoutMs, resolve };
if (deadlineMs !== null) { if (timeoutMs !== null) {
const delay = Math.max(0, deadlineMs - Date.now()); const delay = Math.max(0, timeoutMs);
waiter.timeoutId = setTimeout(() => { waiter.timeoutId = setTimeout(() => {
this.removeWaiter(waiter); this.removeWaiter(waiter);
resolve(null); resolve(null);
@@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur
return drained.concat(remote); 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) { if (this.localQueue.length > 0) {
return this.localQueue.shift() ?? null; return this.localQueue.shift() ?? null;
} }
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now()); const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs);
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000); const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000);
if (deadlineMs !== null && blockMs === 0) { if (timeoutMs !== null && blockMs === 0) {
return null; return null;
} }
@@ -222,7 +222,7 @@ export class TurnDaemonLifecycle {
continue; continue;
} }
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs)); const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
if (command) { if (command) {
await this.handleCommand(command); await this.handleCommand(command);
} }
@@ -268,7 +268,7 @@ export class TurnDaemonLifecycle {
} }
private async waitForResume(): Promise<void> { private async waitForResume(): Promise<void> {
const command = await this.controlQueue.waitUntil(null); const command = await this.controlQueue.waitFor(null);
if (command) { if (command) {
await this.handleCommand(command); await this.handleCommand(command);
} }
+1 -1
View File
@@ -82,7 +82,7 @@ export interface TurnStateStore {
export interface TurnDaemonControlQueue { export interface TurnDaemonControlQueue {
enqueue(command: TurnDaemonCommand): void; enqueue(command: TurnDaemonCommand): void;
drain(): Promise<TurnDaemonCommand[]>; drain(): Promise<TurnDaemonCommand[]>;
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>; waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null>;
getDepth(): number; getDepth(): number;
} }
@@ -18,6 +18,10 @@ interface MessageRow {
type: string; type: string;
time: Date; time: Date;
validUntil: Date; validUntil: Date;
actionType: string;
actionStatus: string;
createdGameTick: bigint;
expiresGameTick: bigint | null;
message: unknown; message: unknown;
} }
@@ -98,11 +102,16 @@ const invalidateMessageIds = async (
): Promise<void> => { ): Promise<void> => {
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))]; const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
if (uniqueIds.length === 0) return; if (uniqueIds.length === 0) return;
const resolvedGameTick = BigInt(world.dateToGameTick(now));
await db.messageAction.updateMany({
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick },
});
await db.message.updateMany({ await db.message.updateMany({
where: { id: { in: uniqueIds } }, where: { id: { in: uniqueIds } },
data: { data: {
validUntil: now, validUntil: now,
validUntilTick: BigInt(world.dateToGameTick(now)), validUntilTick: resolvedGameTick,
}, },
}); });
}; };
@@ -113,40 +122,53 @@ const validateActor = async (options: {
requestId?: string; requestId?: string;
userId: string; userId: string;
generalId: number; generalId: number;
}): Promise<Date> => { }): Promise<{ processingGameTick: number }> => {
const actor = options.world.getGeneralById(options.generalId); const actor = options.world.getGeneralById(options.generalId);
if (!actor || actor.userId !== options.userId) { if (!actor || actor.userId !== options.userId) {
throw new Error('messageRespond general owner does not match command user.'); 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({ const event = await options.db.inputEvent.findUnique({
where: { requestId: options.requestId }, 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) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') { if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
throw new Error('ENGINE input event actor or type does not match 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 ( const fetchMessageForUpdate = async (
db: GamePrisma.TransactionClient, db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
messageId: number, messageId: number,
now: Date currentGameTick: number
): Promise<MessageRow | null> => { ): Promise<MessageRow | null> => {
const currentTick = BigInt(world.dateToGameTick(now));
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql` const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
SELECT id, mailbox, type, time, valid_until AS "validUntil", message SELECT
FROM message envelope.id,
WHERE id = ${messageId} envelope.mailbox,
AND ( envelope.type,
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick}) envelope.time,
OR (valid_until_tick IS NULL AND valid_until > ${now}) 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 LIMIT 1
FOR UPDATE FOR UPDATE OF envelope, action
`); `);
return rows[0] ?? null; return rows[0] ?? null;
}; };
@@ -165,7 +187,7 @@ const respondToScout = async (options: {
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) { if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' }; 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: '유효하지 않은 등용장입니다.' }; return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
} }
@@ -186,18 +208,17 @@ const respondToScout = async (options: {
} }
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql` const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
SELECT id SELECT envelope.id
FROM message FROM message AS envelope
WHERE mailbox = ${payload.src.generalId} JOIN message_action AS action ON action.message_id = envelope.id
AND type = 'private' WHERE envelope.mailbox = ${payload.src.generalId}
AND dest = mailbox AND envelope.type = 'private'
AND id <> ${row.id} AND envelope.dest = envelope.mailbox
AND ( AND envelope.id <> ${row.id}
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))}) AND action.status = 'PENDING'
OR (valid_until_tick IS NULL AND valid_until > ${now}) AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
) AND action.action_type = 'scout'
AND message->'option'->>'action' = 'scout' FOR UPDATE OF envelope, action
FOR UPDATE
`); `);
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now); await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
world.queueMessage({ world.queueMessage({
@@ -327,6 +348,7 @@ const respondToRaiseInvader = async (options: {
}, },
event event
); );
await invalidateMessageIds(db, world, [row.id], world.gameTickToDate(alignment.alignedTick));
return { ok: true, action: 'raiseInvader', reason: 'success' }; return { ok: true, action: 'raiseInvader', reason: 'success' };
}; };
@@ -354,13 +376,14 @@ export const respondToActionableMessage = async (options: {
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>; authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
}) => Promise<ClockReconciliationResult>; }) => Promise<ClockReconciliationResult>;
}): Promise<ActionableMessageResponseResult> => { }): Promise<ActionableMessageResponseResult> => {
const acceptedAt = await validateActor(options); const accepted = await validateActor(options);
const now = options.world.getGameNow(acceptedAt); const now = options.world.gameTickToDate(accepted.processingGameTick);
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now); const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' }; if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
const payload = parsePayload(row.message); const payload = parsePayload(row.message);
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' }; if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
const action = asRecord(payload.option).action; const action = asRecord(payload.option).action;
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
if (action === 'scout') { if (action === 'scout') {
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now }); return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
} }
+130 -26
View File
@@ -147,7 +147,7 @@ const lockWorld = async (db: GamePrisma.TransactionClient): Promise<number> => {
return rows[0]!.id; return rows[0]!.id;
}; };
const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigint): Promise<void> => { 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 general ORDER BY id FOR UPDATE`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql` await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM auction SELECT id FROM auction
@@ -155,10 +155,18 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigin
ORDER BY id FOR UPDATE ORDER BY id FOR UPDATE
`); `);
await db.$queryRaw<IdRow[]>(GamePrisma.sql` await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM message SELECT bid.id
WHERE valid_until_tick IS NOT NULL AND valid_until_tick >= ${cutTick} FROM auction_bid AS bid
ORDER BY id FOR UPDATE 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` await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
`); `);
@@ -175,7 +183,8 @@ const readParticipantSnapshots = async (
worldStateId: number, worldStateId: number,
cutTick: bigint cutTick: bigint
): Promise<ParticipantSnapshot[]> => { ): Promise<ParticipantSnapshot[]> => {
const [world, generals, auctions, messages, votes, pool, npcTokens, commands] = await Promise.all([ const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] =
await Promise.all([
db.worldState.findUniqueOrThrow({ db.worldState.findUniqueOrThrow({
where: { id: worldStateId }, where: { id: worldStateId },
select: { select: {
@@ -188,17 +197,32 @@ const readParticipantSnapshots = async (
}), }),
db.general.findMany({ db.general.findMany({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { id: true, turnTick: true, recentWarTick: true }, select: { id: true, turnTick: true, recentWarTick: true, meta: true },
}), }),
db.auction.findMany({ db.auction.findMany({
where: { status: { in: ['OPEN', 'FINALIZING'] } }, where: { status: { in: ['OPEN', 'FINALIZING'] } },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { id: true, status: true, openTick: true, closeTick: true }, select: { id: true, status: true, openTick: true, closeTick: true },
}), }),
db.message.findMany({ db.auctionBid.findMany({
where: { validUntilTick: { not: null, gte: cutTick } }, where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { id: true, timeTick: true, validUntilTick: true }, 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({ db.votePoll.findMany({
where: { closedAt: null }, where: { closedAt: null },
@@ -246,6 +270,18 @@ const readParticipantSnapshots = async (
'KEEP', 'KEEP',
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick })) 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( snapshot(
'auction-open-occurrence', 'auction-open-occurrence',
'KEEP', 'KEEP',
@@ -256,21 +292,34 @@ const readParticipantSnapshots = async (
'SHIFT', 'SHIFT',
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick })) auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
), ),
snapshot('auction-bid-occurrence', 'KEEP', auctionBids),
snapshot( snapshot(
'auction-finalizing-recovery', 'auction-finalizing-recovery',
'REBUILD', 'REBUILD',
auctions.map(({ id, status }) => ({ id, status })) auctions.map(({ id, status }) => ({ id, status }))
), ),
snapshot( snapshot(
'message-occurrence', 'message-action-occurrence',
'KEEP', 'KEEP',
messages.map(({ id, timeTick }) => ({ id, timeTick })) messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick }))
), ),
snapshot( snapshot(
'message-expiry', 'message-action-expiry',
'SHIFT', 'SHIFT',
messages.map(({ id, validUntilTick }) => ({ id, validUntilTick })) 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( snapshot(
'vote-start-occurrence', 'vote-start-occurrence',
'KEEP', 'KEEP',
@@ -283,7 +332,7 @@ const readParticipantSnapshots = async (
), ),
snapshot('select-pool-reservation', 'SHIFT', pool), snapshot('select-pool-reservation', 'SHIFT', pool),
snapshot('npc-selection-window', 'SHIFT', npcTokens), snapshot('npc-selection-window', 'SHIFT', npcTokens),
snapshot('accepted-command-coordinate', 'KEEP', commands), snapshot('daemon-command-coordinate', 'KEEP', commands),
snapshot('movable-json-rule-anchors', 'SHIFT', [ snapshot('movable-json-rule-anchors', 'SHIFT', [
{ {
lastTurnTime: Reflect.get(meta, 'lastTurnTime'), lastTurnTime: Reflect.get(meta, 'lastTurnTime'),
@@ -429,15 +478,20 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => { const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
const shift = BigInt(shiftTicks); const shift = BigInt(shiftTicks);
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift; const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
const [general, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([ const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }), 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({ db.auction.aggregate({
_max: { closeTick: true }, _max: { closeTick: true },
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } }, where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
}), }),
db.message.aggregate({ db.messageAction.aggregate({
_max: { validUntilTick: true }, _max: { expiresGameTick: true },
where: { validUntilTick: { not: null, lt: BigInt(MAX_SAFE_GAME_TICK) } }, where: { status: 'PENDING', expiresGameTick: { not: null } },
}), }),
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }), db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
db.selectPoolEntry.aggregate({ db.selectPoolEntry.aggregate({
@@ -452,8 +506,9 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
]); ]);
const values: Array<[string, bigint | null]> = [ const values: Array<[string, bigint | null]> = [
['general.turn_tick', general._max.turnTick], ['general.turn_tick', general._max.turnTick],
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
['auction.close_tick', auction._max.closeTick], ['auction.close_tick', auction._max.closeTick],
['message.valid_until_tick', message._max.validUntilTick], ['message_action.expires_game_tick', message._max.expiresGameTick],
['vote_poll.end_tick', vote._max.endTick], ['vote_poll.end_tick', vote._max.endTick],
['select_pool.reserved_until_tick', pool._max.reservedUntilTick], ['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick], ['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
@@ -493,6 +548,32 @@ const applyParticipantShift = async (
WHERE turn_tick IS NOT NULL 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( affected.set(
'auction-deadline', 'auction-deadline',
await db.$executeRaw(GamePrisma.sql` await db.$executeRaw(GamePrisma.sql`
@@ -504,14 +585,37 @@ const applyParticipantShift = async (
`) `)
); );
affected.set( affected.set(
'message-expiry', '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` await db.$executeRaw(GamePrisma.sql`
UPDATE message WITH shifted AS (
SET valid_until_tick = valid_until_tick + ${shiftTicks}, UPDATE message_action
valid_until = valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' SET expires_game_tick = expires_game_tick + ${shiftTicks},
WHERE valid_until_tick IS NOT NULL clock_revision = ${targetRevision},
AND valid_until_tick >= ${cutTick} deadline_generation = ${targetGeneration},
AND valid_until_tick < ${BigInt(MAX_SAFE_GAME_TICK)} 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( affected.set(
+2 -3
View File
@@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({
type: z.literal('auctionFinalize'), type: z.literal('auctionFinalize'),
auctionId: zFiniteNumber, auctionId: zFiniteNumber,
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(), expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
expectedCloseTick: zSafeInteger.optional(), expectedCloseTick: zSafeInteger,
}); });
const zAuctionOpen = z.object({ const zAuctionOpen = z.object({
@@ -60,7 +60,6 @@ const zAuctionBid = z.object({
auctionId: zFiniteNumber, auctionId: zFiniteNumber,
generalId: zFiniteNumber, generalId: zFiniteNumber,
amount: zFiniteNumber, amount: zFiniteNumber,
acceptedGameTick: zSafeInteger.optional(),
tryExtendCloseDate: z.boolean().optional(), tryExtendCloseDate: z.boolean().optional(),
}); });
@@ -427,7 +426,7 @@ const zSelectPoolReserve = z
requestId: z.string().optional(), requestId: z.string().optional(),
userId: z.string().min(1), userId: z.string().min(1),
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]), 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(), acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
}) })
.strict(); .strict();
+13 -31
View File
@@ -7,6 +7,7 @@ import {
writeReadModelChangeJournal, writeReadModelChangeJournal,
enqueuePrivateMessageWebPush, enqueuePrivateMessageWebPush,
enqueueWebPushOutboxEvents, enqueueWebPushOutboxEvents,
persistMessageEnvelope,
type InputJsonValue, type InputJsonValue,
type ReadModelJournalWriteResult, type ReadModelJournalWriteResult,
type TurnEngineCityUpdateInput, type TurnEngineCityUpdateInput,
@@ -1862,37 +1863,18 @@ export const createDatabaseTurnHooks = async (
await sendMessage( await sendMessage(
{ {
insertMessage: async (draft: MessageRecordDraft) => { insertMessage: async (draft: MessageRecordDraft) => {
const toTickOrNull = (date: Date): bigint | null => { const clock = world.getGameClockState();
try { const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
return BigInt(world.dateToGameTick(date)); const expiresGameTick =
} catch { typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000
// Legacy messages may use year 9999 as an ? null
// effectively-unbounded expiry, beyond the : BigInt(world.dateToGameTick(draft.validUntil));
// safe JavaScript tick range. const id = await persistMessageEnvelope(prisma, draft, {
return null; occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
} clockRevision: BigInt(clock.revision),
}; deadlineGeneration: BigInt(clock.deadlineGeneration),
const rows = await prisma.$queryRaw<Array<{ id: number }>>` expiresGameTick,
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.');
}
await enqueuePrivateMessageWebPush(prisma, draft, id); await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox); persistedMessageMailboxes.push(draft.mailbox);
return id; return id;
+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'; import { isRecord } from '@sammo-ts/common';
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED'; export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
@@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async (
continue; continue;
} }
const terminal = result.status !== 'PARTIAL'; const terminal = result.status !== 'PARTIAL';
const updated = await prisma.gatewayRuntimeAction.updateMany({ const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6));
where: { const updated = await prisma.$queryRaw<Array<{ id: string }>>(GatewayPrisma.sql`
id: action.id, UPDATE gateway_runtime_action
status: { in: ['REQUESTED', 'PARTIAL'] }, SET status = ${result.status}::"GatewayRuntimeActionStatus",
}, detail = ${result.detail ?? null},
data: { handler = 'turn-daemon',
status: result.status, handled_at = CASE
detail: result.detail ?? null, WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
handler: 'turn-daemon', ELSE NULL
handledAt: terminal ? new Date() : null, END,
attempts: { increment: 1 }, attempts = attempts + 1,
nextAttemptAt: terminal next_attempt_at = CASE
? null WHEN ${terminal} THEN NULL
: new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))), ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
}, + ${retryDelayMs} * INTERVAL '1 millisecond'
}); END,
if (terminal && updated.count > 0) { 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); await options.onActionApplied?.(actionRecord, result);
} }
} }
@@ -1,3 +1,5 @@
import { performance } from 'node:perf_hooks';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common'; import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra'; import { createGatewayPostgresConnector } from '@sammo-ts/infra';
@@ -42,7 +44,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return { return {
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다. // 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
async shouldPause(): Promise<boolean> { async shouldPause(): Promise<boolean> {
const now = Date.now(); const now = performance.now();
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) { if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
return cachedPause; return cachedPause;
} }
@@ -1,3 +1,5 @@
import { performance } from 'node:perf_hooks';
import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js'; import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { InMemoryTurnWorld, TurnCalendarContext } from './inMemoryWorld.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> { 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 deadlineMs = startMs + Math.max(0, budget.budgetMs);
const isBudgetExpired = () => Date.now() >= deadlineMs; const isBudgetExpired = () => performance.now() >= deadlineMs;
if (isWorldUnited(this.world)) { if (isWorldUnited(this.world)) {
return { return {
lastTurnTime: this.world.getState().lastTurnTime.toISOString(), lastTurnTime: this.world.getState().lastTurnTime.toISOString(),
processedGenerals: 0, processedGenerals: 0,
processedTurns: 0, processedTurns: 0,
durationMs: Math.max(0, Date.now() - startMs), durationMs: Math.max(0, performance.now() - startMs),
partial: false, partial: false,
checkpoint, checkpoint,
}; };
@@ -171,7 +173,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
lastTurnTime, lastTurnTime,
processedGenerals, processedGenerals,
processedTurns, processedTurns,
durationMs: Math.max(0, Date.now() - startMs), durationMs: Math.max(0, performance.now() - startMs),
partial, partial,
checkpoint: nextCheckpoint, checkpoint: nextCheckpoint,
}; };
@@ -310,7 +310,7 @@ export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
return '알수없음'; return '알수없음';
}; };
export const executeInheritanceAction = async (options: { const executeInheritanceActionMutation = async (options: {
db: GamePrisma.TransactionClient; db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld; world: InMemoryTurnWorld;
command: InheritanceActionCommand; command: InheritanceActionCommand;
@@ -668,3 +668,59 @@ export const executeInheritanceAction = async (options: {
}); });
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost }; 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;
};
@@ -1,6 +1,6 @@
import { randomInt } from 'node:crypto'; 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 { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
GamePrisma, GamePrisma,
@@ -84,7 +84,9 @@ export interface NpcPossessionSelectionObserver {
interface NpcSelectionTokenRow { interface NpcSelectionTokenRow {
ownerUserId: string; ownerUserId: string;
validUntil: Date; validUntil: Date;
validUntilTick: bigint | null;
pickMoreFrom: Date; pickMoreFrom: Date;
pickMoreFromTick: bigint | null;
pickResult: unknown; pickResult: unknown;
nonce: number; nonce: number;
} }
@@ -105,8 +107,8 @@ const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTi
export const buildNpcSelectionTokenSeed = ( export const buildNpcSelectionTokenSeed = (
hiddenSeed: string | number, hiddenSeed: string | number,
ownerIdentity: string | number, ownerIdentity: string | number,
acceptedGameTick: number createdGameTick: number
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick); ): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, createdGameTick);
const readHiddenSeed = (worldState: WorldStateRow): string | number => { const readHiddenSeed = (worldState: WorldStateRow): string | number => {
const meta = asRecord(worldState.meta); const meta = asRecord(worldState.meta);
@@ -159,15 +161,22 @@ const parsePickResult = (value: unknown): Record<string, NpcPossessionCandidate>
}; };
const toReservation = ( const toReservation = (
token: Pick<NpcSelectionTokenRow, 'validUntil' | 'pickMoreFrom' | 'pickResult' | 'nonce'>, token: Pick<
now: Date NpcSelectionTokenRow,
'validUntil' | 'validUntilTick' | 'pickMoreFrom' | 'pickMoreFromTick' | 'pickResult' | 'nonce'
>,
currentGameTick: number,
ticksPerSecond: number
): NpcPossessionReservation => { ): NpcPossessionReservation => {
if (token.validUntilTick === null || token.pickMoreFromTick === null) {
return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보의 GAME_TIME 기한이 없습니다.');
}
const pickResult = parsePickResult(token.pickResult); const pickResult = parsePickResult(token.pickResult);
return { return {
tokenNonce: token.nonce, tokenNonce: token.nonce,
validUntil: token.validUntil.toISOString(), validUntil: token.validUntil.toISOString(),
pickMoreFrom: token.pickMoreFrom.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( candidates: Object.values(pickResult).sort(
(left, right) => (left, right) =>
left.stats.leadership + left.stats.leadership +
@@ -289,16 +298,18 @@ export const reserveNpcPossessionCandidates = async (options: {
refresh?: boolean; refresh?: boolean;
keepIds?: number[]; keepIds?: number[];
now?: Date; now?: Date;
acceptedGameTick: number; createdGameTick: number;
selectionObserver?: NpcPossessionSelectionObserver; selectionObserver?: NpcPossessionSelectionObserver;
}): Promise<NpcPossessionReservation> => { }): Promise<NpcPossessionReservation> => {
const { db, worldState, userId } = options; const { db, worldState, userId } = options;
requireNpcPossessionWorld(worldState); requireNpcPossessionWorld(worldState);
const now = truncateToSeconds(options.now ?? new Date()); const now = truncateToSeconds(options.now ?? new Date());
if (!Number.isSafeInteger(options.acceptedGameTick)) { if (!Number.isSafeInteger(options.createdGameTick)) {
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.'); fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 생성 tick이 올바르지 않습니다.');
} }
await lockNpcPossession(db, userId); 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 } })) { if (await db.general.findFirst({ where: { userId }, select: { id: true } })) {
fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다'); fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다');
@@ -324,14 +335,20 @@ export const reserveNpcPossessionCandidates = async (options: {
if (options.refresh) { if (options.refresh) {
fail('CONFLICT', 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.'); 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({ await db.npcSelectionToken.deleteMany({
where: { where: {
ownerUserId: userId, ownerUserId: userId,
nonce: existing.nonce, nonce: existing.nonce,
validUntil: { lt: now }, OR: [
{ validUntilTick: null },
{ validUntilTick: { lt: BigInt(options.createdGameTick) } },
],
}, },
}); });
existing = null; existing = null;
@@ -339,7 +356,7 @@ export const reserveNpcPossessionCandidates = async (options: {
const kept: Record<string, NpcPossessionCandidate> = {}; const kept: Record<string, NpcPossessionCandidate> = {};
if (existing && options.refresh) { if (existing && options.refresh) {
if (now.getTime() < existing.pickMoreFrom.getTime()) { if (existing.pickMoreFromTick === null || options.createdGameTick < Number(existing.pickMoreFromTick)) {
fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다'); fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다');
} }
const oldPick = parsePickResult(existing.pickResult); const oldPick = parsePickResult(existing.pickResult);
@@ -352,16 +369,16 @@ export const reserveNpcPossessionCandidates = async (options: {
} }
// Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다. // Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다.
if (Object.keys(kept).length === Object.keys(oldPick).length) { if (Object.keys(kept).length === Object.keys(oldPick).length) {
return toReservation(existing, now); return toReservation(existing, options.createdGameTick, ticksPerSecond);
} }
} else if (existing) { } else if (existing) {
return toReservation(existing, now); return toReservation(existing, options.createdGameTick, ticksPerSecond);
} }
const reservedRows = await db.npcSelectionToken.findMany({ const reservedRows = await db.npcSelectionToken.findMany({
where: { where: {
ownerUserId: { not: userId }, ownerUserId: { not: userId },
validUntil: { gte: now }, validUntilTick: { gte: BigInt(options.createdGameTick) },
}, },
select: { pickResult: true }, select: { pickResult: true },
}); });
@@ -397,16 +414,19 @@ export const reserveNpcPossessionCandidates = async (options: {
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId))) generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
); );
const selectionRng = new LiteHashDRBG( const selectionRng = new LiteHashDRBG(
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick) buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.createdGameTick)
); );
const rng = options.selectionObserver?.onRandomDraw const rng = options.selectionObserver?.onRandomDraw
? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw) ? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw)
: new RandUtil(selectionRng); : new RandUtil(selectionRng);
const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw); const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw);
const turnTermMinutes = resolveTurnTermMinutes(worldState); const validSeconds = Math.max(VALID_SECONDS, turnTermMinutes * 40);
const validUntil = new Date(now.getTime() + Math.max(VALID_SECONDS, turnTermMinutes * 40) * 1000); 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( 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); const nonce = randomInt(0, 0x10000000);
@@ -415,7 +435,9 @@ export const reserveNpcPossessionCandidates = async (options: {
where: { ownerUserId: userId, nonce: existing.nonce }, where: { ownerUserId: userId, nonce: existing.nonce },
data: { data: {
validUntil, validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: refreshedPickMoreFrom, pickMoreFrom: refreshedPickMoreFrom,
pickMoreFromTick: BigInt(pickMoreFromTick),
pickResult: pickResult as GamePrisma.InputJsonValue, pickResult: pickResult as GamePrisma.InputJsonValue,
nonce, nonce,
}, },
@@ -423,7 +445,18 @@ export const reserveNpcPossessionCandidates = async (options: {
if (updated.count === 0) { if (updated.count === 0) {
fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요'); 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 { try {
@@ -431,7 +464,9 @@ export const reserveNpcPossessionCandidates = async (options: {
data: { data: {
ownerUserId: userId, ownerUserId: userId,
validUntil, validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: FIRST_PICK_MORE_FROM, pickMoreFrom: FIRST_PICK_MORE_FROM,
pickMoreFromTick: BigInt(options.createdGameTick),
pickResult: pickResult as GamePrisma.InputJsonValue, pickResult: pickResult as GamePrisma.InputJsonValue,
nonce, nonce,
}, },
@@ -442,7 +477,18 @@ export const reserveNpcPossessionCandidates = async (options: {
} }
throw error; 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: { export const possessNpcGeneral = async (options: {
@@ -455,11 +501,13 @@ export const possessNpcGeneral = async (options: {
ownerLegacyPenalty?: Record<string, unknown>; ownerLegacyPenalty?: Record<string, unknown>;
generalId: number; generalId: number;
tokenNonce: number; tokenNonce: number;
acceptedAt: Date; requestedAtWall: Date;
processingGameTick: number;
}): Promise<{ ok: true; generalId: number }> => { }): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, generalId, acceptedAt } = options; const { db, world, worldState, userId, generalId, requestedAtWall } = options;
// queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다. if (!Number.isSafeInteger(options.processingGameTick)) {
const tokenAcceptedAt = truncateToSeconds(acceptedAt); fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 처리 tick이 올바르지 않습니다.');
}
requireNpcPossessionWorld(worldState); requireNpcPossessionWorld(worldState);
await lockNpcPossession(db, userId); await lockNpcPossession(db, userId);
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`); await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
@@ -475,7 +523,7 @@ export const possessNpcGeneral = async (options: {
where: { where: {
ownerUserId: userId, ownerUserId: userId,
nonce: options.tokenNonce, nonce: options.tokenNonce,
validUntil: { gte: tokenAcceptedAt }, validUntilTick: { gte: BigInt(options.processingGameTick) },
}, },
})) as NpcSelectionTokenRow | null; })) as NpcSelectionTokenRow | null;
if (!token) { if (!token) {
@@ -501,7 +549,7 @@ export const possessNpcGeneral = async (options: {
return fail('NOT_FOUND', '장수 등록에 실패했습니다.'); return fail('NOT_FOUND', '장수 등록에 실패했습니다.');
} }
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, acceptedAt); const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, requestedAtWall);
world.updateGeneral(generalId, { world.updateGeneral(generalId, {
userId, userId,
npcState: 1, npcState: 1,
@@ -522,7 +570,7 @@ export const possessNpcGeneral = async (options: {
where: { generalId }, where: { generalId },
update: { update: {
userId, userId,
lastRefresh: acceptedAt, lastRefresh: requestedAtWall,
refresh: 0, refresh: 0,
refreshTotal: 0, refreshTotal: 0,
refreshScore: 0, refreshScore: 0,
@@ -531,7 +579,7 @@ export const possessNpcGeneral = async (options: {
create: { create: {
generalId, generalId,
userId, userId,
lastRefresh: acceptedAt, lastRefresh: requestedAtWall,
}, },
}); });
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId } }); 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 { isRecord } from '@sammo-ts/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -75,6 +80,7 @@ const buildTurnListFromRows = (
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`; const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & { type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & {
$queryRaw?<T>(query: GamePrisma.Sql): Promise<T>;
generalTurnRevision?: Pick< generalTurnRevision?: Pick<
NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>, NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>,
'findUnique' | 'createMany' | 'updateMany' 'findUnique' | 'createMany' | 'updateMany'
@@ -313,8 +319,17 @@ export class InMemoryReservedTurnStore {
} }
} }
private getLeaseExpiresAt(): Date { private getLeaseExpiresAt(nowWall: Date): Date {
return new Date(Date.now() + this.leaseDurationMs); 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> { private async acquireGeneralLease(generalId: number): Promise<boolean> {
@@ -322,7 +337,7 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) { if (!revisionStore) {
return false; return false;
} }
const now = new Date(); const now = await this.readDatabaseWallTime();
const previous = (await revisionStore.findUnique({ where: { generalId } })) as { const previous = (await revisionStore.findUnique({ where: { generalId } })) as {
leaseOwner: string | null; leaseOwner: string | null;
leaseExpiresAt: Date | null; leaseExpiresAt: Date | null;
@@ -331,7 +346,7 @@ export class InMemoryReservedTurnStore {
previous?.leaseOwner === this.leaseOwner && previous?.leaseOwner === this.leaseOwner &&
previous.leaseExpiresAt !== null && previous.leaseExpiresAt !== null &&
previous.leaseExpiresAt.getTime() > now.getTime(); previous.leaseExpiresAt.getTime() > now.getTime();
const leaseExpiresAt = this.getLeaseExpiresAt(); const leaseExpiresAt = this.getLeaseExpiresAt(now);
let claimed = await revisionStore.updateMany({ let claimed = await revisionStore.updateMany({
where: { where: {
generalId, generalId,
@@ -372,7 +387,7 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) { if (!revisionStore) {
return false; return false;
} }
const now = new Date(); const now = await this.readDatabaseWallTime();
const previous = (await revisionStore.findUnique({ const previous = (await revisionStore.findUnique({
where: { nationId_officerLevel: { nationId, officerLevel } }, where: { nationId_officerLevel: { nationId, officerLevel } },
})) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null; })) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null;
@@ -380,7 +395,7 @@ export class InMemoryReservedTurnStore {
previous?.leaseOwner === this.leaseOwner && previous?.leaseOwner === this.leaseOwner &&
previous.leaseExpiresAt !== null && previous.leaseExpiresAt !== null &&
previous.leaseExpiresAt.getTime() > now.getTime(); previous.leaseExpiresAt.getTime() > now.getTime();
const leaseExpiresAt = this.getLeaseExpiresAt(); const leaseExpiresAt = this.getLeaseExpiresAt(now);
let claimed = await revisionStore.updateMany({ let claimed = await revisionStore.updateMany({
where: { where: {
nationId, nationId,
@@ -651,12 +666,13 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) { if (!revisionStore) {
return false; return false;
} }
const leaseExpiresAt = this.getLeaseExpiresAt(); const now = await this.readDatabaseWallTime(prisma);
const leaseExpiresAt = this.getLeaseExpiresAt(now);
const where = this.leasedGeneralIds.has(generalId) const where = this.leasedGeneralIds.has(generalId)
? { generalId, leaseOwner: this.leaseOwner } ? { generalId, leaseOwner: this.leaseOwner }
: { : {
generalId, 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({ let claimed = await revisionStore.updateMany({
where, where,
@@ -721,13 +737,14 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) { if (!revisionStore) {
return false; return false;
} }
const leaseExpiresAt = this.getLeaseExpiresAt(); const now = await this.readDatabaseWallTime(prisma);
const leaseExpiresAt = this.getLeaseExpiresAt(now);
const where = this.leasedNationKeys.has(key) const where = this.leasedNationKeys.has(key)
? { nationId, officerLevel, leaseOwner: this.leaseOwner } ? { nationId, officerLevel, leaseOwner: this.leaseOwner }
: { : {
nationId, nationId,
officerLevel, 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({ let claimed = await revisionStore.updateMany({
where, where,
@@ -1,5 +1,6 @@
import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { import {
buildGameEventChannel, buildGameEventChannel,
@@ -89,8 +90,8 @@ const shiftTournamentClock = async (
}; };
const lockKey = `${stateKey}:mutation-lock`; const lockKey = `${stateKey}:mutation-lock`;
const token = randomUUID(); const token = randomUUID();
const deadline = Date.now() + 2_000; const deadline = performance.now() + 2_000;
while (Date.now() < deadline) { while (performance.now() < deadline) {
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 }); const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) { if (acquired) {
try { try {
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { import {
buildGameEventChannel, buildGameEventChannel,
@@ -158,8 +159,8 @@ const reprojectTournamentClock = async (
}; };
const lockKey = `${stateKey}:mutation-lock`; const lockKey = `${stateKey}:mutation-lock`;
const token = randomUUID(); const token = randomUUID();
const deadline = Date.now() + 2_000; const deadline = performance.now() + 2_000;
while (Date.now() < deadline) { while (performance.now() < deadline) {
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 }); const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) { if (acquired) {
try { try {
+52 -62
View File
@@ -268,13 +268,10 @@ const toReservationDto = (
world: InMemoryTurnWorld world: InMemoryTurnWorld
): Promise<SelectPoolReservationDto> => { ): Promise<SelectPoolReservationDto> => {
const first = rows[0]; const first = rows[0];
if (!first || (first.reservedUntilTick === null && first.reservedUntil === null)) { if (!first || first.reservedUntilTick === null) {
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.'); throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
} }
const expiresAt = const expiresAt = world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
first.reservedUntilTick === null
? first.reservedUntil!
: world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
const poolName = resolvePoolName(worldState); const poolName = resolvePoolName(worldState);
if (!poolName || !SUPPORTED_POOLS.has(poolName)) { if (!poolName || !SUPPORTED_POOLS.has(poolName)) {
throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다'); throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
@@ -355,6 +352,23 @@ const readNextChangeAt = (generalMeta: unknown): Date | null => {
return Number.isNaN(parsed.getTime()) ? null : parsed; 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 getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
const meta = asRecord(worldState.meta); const meta = asRecord(worldState.meta);
const value = meta.hiddenSeed ?? meta.seed; const value = meta.hiddenSeed ?? meta.seed;
@@ -371,18 +385,8 @@ const toSafeReservationTick = (value: bigint | number, uniqueName: string): numb
return tick; return tick;
}; };
const resolveAcceptedGameTick = (world: InMemoryTurnWorld, now: Date): number => { const isReservationActive = (row: SelectPoolRow, nowTick: number): boolean =>
const tick = world.dateToGameTick(now); row.reservedUntilTick !== null && toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick;
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 lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => { const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`); await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
@@ -392,7 +396,6 @@ const requireSelectionToken = async (
db: DatabaseClient, db: DatabaseClient,
userId: string, userId: string,
uniqueName: string, uniqueName: string,
now: Date,
nowTick: number nowTick: number
): Promise<SelectPoolRow> => { ): Promise<SelectPoolRow> => {
const token = await db.selectPoolEntry.findFirst({ const token = await db.selectPoolEntry.findFirst({
@@ -400,10 +403,7 @@ const requireSelectionToken = async (
ownerUserId: userId, ownerUserId: userId,
uniqueName, uniqueName,
generalId: null, generalId: null,
OR: [ reservedUntilTick: { gte: BigInt(nowTick) },
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
}, },
}); });
if (!token) { if (!token) {
@@ -438,18 +438,13 @@ export const reserveSelectionPool = async (options: {
worldState: WorldStateRow; worldState: WorldStateRow;
userId: string; userId: string;
now?: Date; now?: Date;
acceptedGameTick?: number; processingGameTick: number;
processingGameTick?: number;
seedOwnerIdentity?: string | number; seedOwnerIdentity?: string | number;
}): Promise<SelectPoolReservationDto> => { }): Promise<SelectPoolReservationDto> => {
const { db, world, worldState, userId } = options; const { db, world, worldState, userId } = options;
requirePoolWorld(worldState); requirePoolWorld(worldState);
const now = options.now ?? new Date(); const now = options.now ?? new Date();
const acceptedGameTick = options.acceptedGameTick ?? resolveAcceptedGameTick(world, now); const processingGameTick = options.processingGameTick;
const processingGameTick = options.processingGameTick ?? acceptedGameTick;
if (!Number.isSafeInteger(acceptedGameTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
}
if (!Number.isSafeInteger(processingGameTick)) { if (!Number.isSafeInteger(processingGameTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.'); fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
} }
@@ -459,14 +454,11 @@ export const reserveSelectionPool = async (options: {
where: { userId }, where: { userId },
select: { id: true, meta: true }, select: { id: true, meta: true },
}); });
const nextChangeAt = general ? readNextChangeAt(general.meta) : null; if (general) assertReselectionCooldown(general.meta, processingGameTick);
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
let currentRows = await synchronizeSelectionPoolWorld(db, world); let currentRows = await synchronizeSelectionPoolWorld(db, world);
const existing = currentRows.filter( const existing = currentRows.filter(
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, now, processingGameTick) (row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, processingGameTick)
); );
if (existing.length > 0) { if (existing.length > 0) {
return toReservationDto(existing, Boolean(general), worldState, world); return toReservationDto(existing, Boolean(general), worldState, world);
@@ -477,7 +469,7 @@ export const reserveSelectionPool = async (options: {
generalId: null, generalId: null,
OR: [ OR: [
{ reservedUntilTick: { lt: BigInt(processingGameTick) } }, { reservedUntilTick: { lt: BigInt(processingGameTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: now } }, { reservedUntilTick: null, reservedUntil: { not: null } },
], ],
}, },
data: { data: {
@@ -504,7 +496,7 @@ export const reserveSelectionPool = async (options: {
const rng = new RandUtil( const rng = new RandUtil(
new LiteHashDRBG( new LiteHashDRBG(
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, acceptedGameTick) buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, processingGameTick)
) )
); );
const poolName = resolvePoolName(worldState)!; const poolName = resolvePoolName(worldState)!;
@@ -578,7 +570,6 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor
const clearUnusedReservations = async ( const clearUnusedReservations = async (
db: DatabaseClient, db: DatabaseClient,
userId: string, userId: string,
now: Date,
nowTick: number nowTick: number
): Promise<void> => { ): Promise<void> => {
await db.selectPoolEntry.updateMany({ await db.selectPoolEntry.updateMany({
@@ -587,7 +578,7 @@ const clearUnusedReservations = async (
OR: [ OR: [
{ ownerUserId: userId }, { ownerUserId: userId },
{ reservedUntilTick: { lt: BigInt(nowTick) } }, { reservedUntilTick: { lt: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: now } }, { reservedUntilTick: null, reservedUntil: { not: null } },
], ],
}, },
data: { data: {
@@ -716,6 +707,7 @@ export const createGeneralFromSelectionPool = async (options: {
now?: Date; now?: Date;
turnScheduleAt?: Date; turnScheduleAt?: Date;
operationalAcceptedAt: Date; operationalAcceptedAt: Date;
processingGameTick: number;
seedOwnerIdentity?: string | number; seedOwnerIdentity?: string | number;
ownerPicture?: string; ownerPicture?: string;
ownerImageServer?: number; ownerImageServer?: number;
@@ -724,7 +716,10 @@ export const createGeneralFromSelectionPool = async (options: {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options; const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState); requirePoolWorld(worldState);
const now = options.now ?? new Date(); 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 lockSelectionUser(db, userId);
await lockSelectionMutationTables(db); await lockSelectionMutationTables(db);
await synchronizeSelectionPoolWorld(db, world); await synchronizeSelectionPoolWorld(db, world);
@@ -735,7 +730,7 @@ export const createGeneralFromSelectionPool = async (options: {
) { ) {
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.'); 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 info = parseCandidate(token);
const poolName = resolvePoolName(worldState)!; const poolName = resolvePoolName(worldState)!;
const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL; const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL;
@@ -777,9 +772,8 @@ export const createGeneralFromSelectionPool = async (options: {
const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now); const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
const age = 20; const age = 20;
const specialityAges = resolveSpecialityAges(worldState, age); const specialityAges = resolveSpecialityAges(worldState, age);
const nextChangeAt = new Date( const nextChangeTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 const nextChangeAt = world.gameTickToDate(nextChangeTick);
);
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config); const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는 // 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다. // 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
@@ -813,6 +807,7 @@ export const createGeneralFromSelectionPool = async (options: {
dex5: isCentennial ? 0 : info.dex[4], dex5: isCentennial ? 0 : info.dex[4],
next_change: nextChangeAt.toISOString(), next_change: nextChangeAt.toISOString(),
nextChangeAt: nextChangeAt.toISOString(), nextChangeAt: nextChangeAt.toISOString(),
next_change_tick: nextChangeTick,
prestart_delete_after: prestartDeleteAfter.toISOString(), prestart_delete_after: prestartDeleteAfter.toISOString(),
...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}), ...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}),
npc_org: 0, npc_org: 0,
@@ -905,10 +900,7 @@ export const createGeneralFromSelectionPool = async (options: {
id: token.id, id: token.id,
ownerUserId: userId, ownerUserId: userId,
generalId: null, generalId: null,
OR: [ reservedUntilTick: { gte: BigInt(nowTick) },
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
}, },
data: { data: {
generalId, generalId,
@@ -925,7 +917,7 @@ export const createGeneralFromSelectionPool = async (options: {
update: { userId, lastRefresh: options.operationalAcceptedAt }, update: { userId, lastRefresh: options.operationalAcceptedAt },
create: { generalId, 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); await synchronizeSelectionPoolWorld(db, world);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이'); const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
@@ -949,11 +941,15 @@ export const reselectGeneralFromSelectionPool = async (options: {
ownerDisplayName: string; ownerDisplayName: string;
uniqueName: string; uniqueName: string;
now?: Date; now?: Date;
processingGameTick: number;
}): Promise<{ ok: true; generalId: number }> => { }): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options; const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState); requirePoolWorld(worldState);
const now = options.now ?? new Date(); 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 lockSelectionUser(db, userId);
await lockSelectionMutationTables(db); await lockSelectionMutationTables(db);
await synchronizeSelectionPoolWorld(db, world); await synchronizeSelectionPoolWorld(db, world);
@@ -968,11 +964,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
if (persistedGeneral.id !== general.id) { if (persistedGeneral.id !== general.id) {
fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.'); fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.');
} }
const nextChangeAt = readNextChangeAt(general.meta); assertReselectionCooldown(general.meta, nowTick);
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) { const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
const info = parseCandidate(token); const info = parseCandidate(token);
const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL; const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL;
@@ -982,10 +975,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
id: token.id, id: token.id,
ownerUserId: userId, ownerUserId: userId,
generalId: null, generalId: null,
OR: [ reservedUntilTick: { gte: BigInt(nowTick) },
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
}, },
data: { data: {
generalId: provisionalGeneralId, generalId: provisionalGeneralId,
@@ -1014,9 +1004,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.'); throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
} }
const cooldown = new Date( const cooldownTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 const cooldown = world.gameTickToDate(cooldownTick);
);
const centennialBaseGeneral = isCentennial const centennialBaseGeneral = isCentennial
? { ? {
...general, ...general,
@@ -1046,6 +1035,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
: {}), : {}),
next_change: cooldown.toISOString(), next_change: cooldown.toISOString(),
nextChangeAt: cooldown.toISOString(), nextChangeAt: cooldown.toISOString(),
next_change_tick: cooldownTick,
...buildScenarioGeneralPoolClaimMeta( ...buildScenarioGeneralPoolClaimMeta(
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }), parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
now now
@@ -1074,7 +1064,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
if (!updated) { if (!updated) {
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.'); throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
} }
await clearUnusedReservations(db, userId, now, nowTick); await clearUnusedReservations(db, userId, nowTick);
await synchronizeSelectionPoolWorld(db, world); await synchronizeSelectionPoolWorld(db, world);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이'); const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
@@ -1,5 +1,9 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; 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 type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic'; import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
import { import {
@@ -119,22 +123,18 @@ interface HighestUnificationBidRow {
meta: unknown; meta: unknown;
} }
const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: MessageRecordDraft): Promise<number> => { const insertMessage = async (
const rows = await transaction.$queryRaw<Array<{ id: number }>>` transaction: GamePrisma.TransactionClient,
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message) world: InMemoryTurnWorld,
VALUES ( draft: MessageRecordDraft
${draft.mailbox}, ): Promise<number> => {
${draft.msgType}, const clock = world.getGameClockState();
${draft.srcId}, const id = await persistMessageEnvelope(transaction, draft, {
${draft.destId}, occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
${draft.time}, clockRevision: BigInt(clock.revision),
${draft.validUntil}, deadlineGeneration: BigInt(clock.deadlineGeneration),
CAST(${JSON.stringify(draft.payload)} AS jsonb) expiresGameTick: null,
) });
RETURNING id
`;
const id = rows[0]?.id;
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
await enqueuePrivateMessageWebPush(transaction, draft, id); await enqueuePrivateMessageWebPush(transaction, draft, id);
return id; return id;
}; };
@@ -252,7 +252,7 @@ const cancelPendingUniqueAuctions = async (
await sendMessage( await sendMessage(
{ {
insertMessage: async (draft) => { insertMessage: async (draft) => {
const messageId = await insertMessage(transaction, draft); const messageId = await insertMessage(transaction, world, draft);
messageMailboxes.push(draft.mailbox); messageMailboxes.push(draft.mailbox);
return messageId; return messageId;
}, },
+26 -46
View File
@@ -278,18 +278,12 @@ const resolveSelectionCommandAcceptedAt = async (
world: InMemoryTurnWorld, world: InMemoryTurnWorld,
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }> command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }>
): Promise<Date> => { ): Promise<Date> => {
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); await resolveCommandAcceptedAt(db, command);
const processingGameTick = Reflect.get(command, 'processingGameTick'); const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) { if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) {
return world.gameTickToDate(processingGameTick); return world.gameTickToDate(processingGameTick);
} }
if (command.acceptedGameTick !== undefined) { throw new Error(`${command.type} requires an authoritative daemon processing game tick.`);
return world.gameTickToDate(command.acceptedGameTick);
}
if (command.acceptedGameAt !== undefined) {
return new Date(command.acceptedGameAt);
}
return world.getGameNow(operationalAcceptedAt);
}; };
const resolveOperationalAcceptedAt = async ( const resolveOperationalAcceptedAt = async (
@@ -417,12 +411,9 @@ async function handleNpcPossessGeneral(
} }
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const processingGameTick = Reflect.get(command, 'processingGameTick'); const processingGameTick = Reflect.get(command, 'processingGameTick');
const acceptedAt = if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick) throw new Error('npcPossessGeneral requires an authoritative daemon processing game tick.');
? ctx.world.gameTickToDate(processingGameTick) }
: command.acceptedGameAt
? new Date(command.acceptedGameAt)
: ctx.world.getGameNow(operationalAcceptedAt);
try { try {
return { return {
type: 'npcPossessGeneral', type: 'npcPossessGeneral',
@@ -436,7 +427,8 @@ async function handleNpcPossessGeneral(
...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}), ...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}),
generalId: command.generalId, generalId: command.generalId,
tokenNonce: command.tokenNonce, tokenNonce: command.tokenNonce,
acceptedAt, requestedAtWall: operationalAcceptedAt,
processingGameTick,
})), })),
}; };
} catch (error) { } catch (error) {
@@ -465,15 +457,11 @@ async function handleSelectPoolCreate(
} }
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const processingGameTick = Reflect.get(command, 'processingGameTick'); const processingGameTick = Reflect.get(command, 'processingGameTick');
const acceptedAt = if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick) throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
? ctx.world.gameTickToDate(processingGameTick) }
: command.acceptedGameTick !== undefined const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
? ctx.world.gameTickToDate(command.acceptedGameTick) const turnScheduleAt = acceptedAt;
: command.acceptedGameAt !== undefined
? new Date(command.acceptedGameAt)
: ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
try { try {
return { return {
type: 'selectPoolCreate', type: 'selectPoolCreate',
@@ -492,6 +480,7 @@ async function handleSelectPoolCreate(
now: acceptedAt, now: acceptedAt,
turnScheduleAt, turnScheduleAt,
operationalAcceptedAt, operationalAcceptedAt,
processingGameTick,
})), })),
}; };
} catch (error) { } catch (error) {
@@ -530,10 +519,7 @@ async function handleSelectPoolReserve(
userId: command.userId, userId: command.userId,
seedOwnerIdentity: command.seedOwnerIdentity, seedOwnerIdentity: command.seedOwnerIdentity,
now: acceptedAt, now: acceptedAt,
...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }), processingGameTick: Reflect.get(command, 'processingGameTick') as number,
...(typeof Reflect.get(command, 'processingGameTick') === 'number'
? { processingGameTick: Reflect.get(command, 'processingGameTick') as number }
: {}),
}), }),
}; };
} catch (error) { } catch (error) {
@@ -572,6 +558,7 @@ async function handleSelectPoolReselect(
ownerDisplayName: command.ownerDisplayName, ownerDisplayName: command.ownerDisplayName,
uniqueName: command.uniqueName, uniqueName: command.uniqueName,
now: acceptedAt, now: acceptedAt,
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
})), })),
}; };
} catch (error) { } catch (error) {
@@ -2745,17 +2732,16 @@ type VotePollValidationRow = {
export const hasVotePollDeadlinePassed = ( export const hasVotePollDeadlinePassed = (
poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>, poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>,
acceptedGameAt: Date, currentGameTick: number
acceptedGameTick: number
): boolean => { ): boolean => {
const endTick = const endTick =
poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick); poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick);
return ( if (poll.closedAt !== null) return true;
poll.closedAt !== null || // No projection and no tick means an intentionally unbounded poll. A
(endTick !== null // projection without its authoritative tick is a broken GAME deadline and
? endTick < BigInt(acceptedGameTick) // therefore fails closed.
: Boolean(poll.endAt && poll.endAt.getTime() < acceptedGameAt.getTime())) if (endTick === null) return poll.endAt !== null;
); return endTick < BigInt(currentGameTick);
}; };
const parseVoteOptionCount = (value: unknown): number => { const parseVoteOptionCount = (value: unknown): number => {
@@ -2790,17 +2776,11 @@ const validateVoteSelectionInTransaction = async (
const poll = rows[0]; const poll = rows[0];
if (!poll) return '설문조사가 없습니다.'; if (!poll) return '설문조사가 없습니다.';
const processingNow = ctx.world.getGameNow(new Date());
const convertedProcessingTick = Reflect.get(command, 'processingGameTick'); const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
const acceptedGameTick = if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick) throw new Error('voteReward requires an authoritative daemon processing game tick.');
? convertedProcessingTick }
: (command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow)); if (hasVotePollDeadlinePassed(poll, convertedProcessingTick)) {
const acceptedGameAt =
command.acceptedGameTick === undefined && convertedProcessingTick === undefined
? processingNow
: ctx.world.gameTickToDate(acceptedGameTick);
if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) {
return '설문조사가 종료되었습니다.'; return '설문조사가 종료되었습니다.';
} }
@@ -87,6 +87,7 @@ const destination = {
color: '#ffffff', color: '#ffffff',
icon: '', icon: '',
}; };
const requestId = 'actionable-message-request';
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
id: 29, id: 29,
@@ -94,6 +95,10 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
type: 'private', type: 'private',
time: new Date('0200-01-01T00:00:00.000Z'), time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'), validUntil: new Date('9999-12-31T00:00:00.000Z'),
actionType: action,
actionStatus: 'PENDING',
createdGameTick: 0n,
expiresGameTick: null,
message: { message: {
src: source, src: source,
dest: destination, dest: destination,
@@ -106,10 +111,25 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
const buildDb = (rows: unknown[][]) => { const buildDb = (rows: unknown[][]) => {
const queryRaw = vi.fn(async () => rows.shift() ?? []); const queryRaw = vi.fn(async () => rows.shift() ?? []);
const updateMany = vi.fn(async () => ({ count: 1 })); const updateMany = vi.fn(async () => ({ count: 1 }));
const actionUpdateMany = vi.fn(async () => ({ count: 1 }));
return { 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, queryRaw,
updateMany, updateMany,
actionUpdateMany,
}; };
}; };
@@ -118,6 +138,22 @@ const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({
}); });
describe('actionable message response', () => { 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 () => { it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => {
const world = buildWorld(); const world = buildWorld();
const row = buildRow('scout'); const row = buildRow('scout');
@@ -128,6 +164,7 @@ describe('actionable message response', () => {
db, db,
world, world,
executor, executor,
requestId,
userId: actor.userId!, userId: actor.userId!,
generalId: actor.id, generalId: actor.id,
messageId: row.id, messageId: row.id,
@@ -162,6 +199,7 @@ describe('actionable message response', () => {
db, db,
world, world,
executor: buildExecutor(false), executor: buildExecutor(false),
requestId,
userId: actor.userId!, userId: actor.userId!,
generalId: actor.id, generalId: actor.id,
messageId: row.id, messageId: row.id,
@@ -173,14 +211,8 @@ describe('actionable message response', () => {
expect(world.peekDirtyState().messages).toHaveLength(0); expect(world.peekDirtyState().messages).toHaveLength(0);
}); });
it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => { it('treats a legacy truthy used value as an invalid scout letter', async () => {
for (const row of [ for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
buildRow('scout', { option: { action: 'scout', used: 1 } }),
{
...buildRow('scout'),
validUntil: new Date('0199-12-31T23:59:59.000Z'),
},
]) {
const world = buildWorld(); const world = buildWorld();
const { db, updateMany } = buildDb([[row]]); const { db, updateMany } = buildDb([[row]]);
const executor = buildExecutor(); const executor = buildExecutor();
@@ -190,6 +222,7 @@ describe('actionable message response', () => {
db, db,
world, world,
executor, executor,
requestId,
userId: actor.userId!, userId: actor.userId!,
generalId: actor.id, generalId: actor.id,
messageId: row.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 () => { it("keeps PHP's special string-zero used value false", async () => {
const world = buildWorld(); const world = buildWorld();
const row = buildRow('scout', { option: { action: 'scout', used: '0' } }); const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
@@ -212,6 +263,7 @@ describe('actionable message response', () => {
db, db,
world, world,
executor, executor,
requestId,
userId: actor.userId!, userId: actor.userId!,
generalId: actor.id, generalId: actor.id,
messageId: row.id, messageId: row.id,
@@ -233,6 +285,7 @@ describe('actionable message response', () => {
db, db,
world, world,
executor, executor,
requestId,
userId: actor.userId!, userId: actor.userId!,
generalId: actor.id, generalId: actor.id,
messageId: row.id, messageId: row.id,
@@ -252,6 +305,7 @@ describe('actionable message response', () => {
db, db,
world, world,
executor: buildExecutor(), executor: buildExecutor(),
requestId,
userId: actor.userId!, userId: actor.userId!,
generalId: actor.id, generalId: actor.id,
messageId: row.id, messageId: row.id,
@@ -271,6 +325,7 @@ describe('actionable message response', () => {
db, db,
world, world,
executor: buildExecutor(), executor: buildExecutor(),
requestId,
userId: actor.userId!, userId: actor.userId!,
generalId: actor.id, generalId: actor.id,
messageId: row.id, messageId: row.id,
@@ -107,6 +107,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'], world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'],
}); });
const amount = finishImmediately ? 500 : 200; const amount = finishImmediately ? 500 : 200;
const requestedAtWall = new Date('2026-08-23T00:00:00.000Z');
const result = await auctionBidder.bid( const result = await auctionBidder.bid(
{ {
type: 'auctionBid', type: 'auctionBid',
@@ -114,8 +115,9 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
auctionId: 31, auctionId: 31,
generalId: general.id, generalId: general.id,
amount, amount,
acceptedGameTick: 100, processingGameTick: 100,
}, requestedAtWall,
} as any,
commandDb as any commandDb as any
); );
await auctionBidder.close(); 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 insert = statements.find((query) => query.strings.join(' ').includes('INSERT INTO auction_bid'));
const update = statements.find((query) => query.strings.join(' ').includes('UPDATE auction')); 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', () => { 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, closeAt, 72_000_000)).toBe(false);
expect(hasAuctionClosePassed(auction, new Date(closeAt.getTime() + 1), 72_000_001)).toBe(true); 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); 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 closeAt = new Date('0190-02-01T00:00:00.000Z');
const auction = { closeAt, closeTick: 72_000_000n }; const auction = { closeAt, closeTick: 72_000_000n };
const world = { const world = {
dateToGameTick: () => 72_000_001, dateToGameTick: () => 72_000_001,
gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)), gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)),
}; };
const processingNow = new Date(closeAt.getTime() + 1); expect(hasAuctionBidClosePassed(auction, world, 72_000_000)).toBe(false);
expect(hasAuctionBidClosePassed(auction, world, 72_000_001)).toBe(true);
expect(hasAuctionBidClosePassed(auction, world, processingNow, 72_000_000)).toBe(false);
expect(hasAuctionBidClosePassed(auction, world, processingNow)).toBe(true);
expect( expect(
normalizeTurnDaemonCommand({ normalizeTurnDaemonCommand({
requestId: 'auction-bid-accepted-tick', requestId: 'auction-bid-accepted-tick',
@@ -161,23 +161,26 @@ describe('resource auction Ref compatibility', () => {
generalId: 7, generalId: 7,
amount: 500, amount: 500,
acceptedGameTick: 72_000_000, 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 () => { 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(result).toMatchObject({ type: 'auctionBid', ok: true });
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe( expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
acceptedAt.getTime() + 100_000 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([ expect(update?.values.filter((value): value is Date => value instanceof Date)).toEqual([
new Date(acceptedAt.getTime() + 100_000), new Date(acceptedAt.getTime() + 100_000),
acceptedAt, acceptedAt,
acceptedAt,
]); ]);
expect(update?.values).not.toContain(processingAt); expect(update?.values).not.toContain(processingAt);
}); });
@@ -27,6 +27,12 @@ import {
import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js'; import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js';
import type { TurnGeneral } from '../src/turn/types.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', () => { describe('unique auction inheritance log compatibility', () => {
it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => { it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => {
const userId = '4c2f2f6d-8a37-4f22-a4f9-1a6f5e4c22ec'; 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'), getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
dateToGameTick: (date: Date) => Math.floor(date.getTime() / 1_000), 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), updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
pushLog: () => {}, pushLog: () => {},
}; };
const result = await openAuction( const result = await openAuction(
{ withDaemonBoundary({
type: 'auctionOpen', type: 'auctionOpen',
userId: 'user-7', userId: 'user-7',
auctionType: 'UNIQUE_ITEM', auctionType: 'UNIQUE_ITEM',
generalId: general.id, generalId: general.id,
amount: 6_000, amount: 6_000,
itemKey: 'che_무기_12_칠성검', itemKey: 'che_무기_12_칠성검',
}, }),
world as unknown as Parameters<typeof openAuction>[1], world as unknown as Parameters<typeof openAuction>[1],
db as unknown as NonNullable<Parameters<typeof openAuction>[2]> db as unknown as NonNullable<Parameters<typeof openAuction>[2]>
); );
@@ -192,6 +199,7 @@ describe('unique auction inheritance log compatibility', () => {
const world = { const world = {
getGameNow: () => closeAt, getGameNow: () => closeAt,
dateToGameTick: () => 72_000_000, dateToGameTick: () => 72_000_000,
gameTickToDate: () => closeAt,
pushLog: vi.fn(), pushLog: vi.fn(),
}; };
const finalizer = await createAuctionFinalizer({ const finalizer = await createAuctionFinalizer({
@@ -201,12 +209,12 @@ describe('unique auction inheritance log compatibility', () => {
await expect( await expect(
finalizer.finalize( finalizer.finalize(
{ withDaemonBoundary({
type: 'auctionFinalize', type: 'auctionFinalize',
auctionId: 31, auctionId: 31,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000, expectedCloseTick: 72_000_000,
}, }),
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]> commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
) )
).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 }); ).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 });
@@ -241,6 +249,7 @@ describe('unique auction inheritance log compatibility', () => {
const world = { const world = {
getGameNow: () => closeAt, getGameNow: () => closeAt,
dateToGameTick: () => nowTick, dateToGameTick: () => nowTick,
gameTickToDate: () => closeAt,
}; };
const finalizer = await createAuctionFinalizer({ const finalizer = await createAuctionFinalizer({
databaseUrl: 'postgresql://unused', 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]>; const db = commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>;
await expect( 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: '경매 마감 시각이 아직 지나지 않았습니다.' }); ).resolves.toMatchObject({ ok: false, reason: '경매 마감 시각이 아직 지나지 않았습니다.' });
nowTick = 72_000_000; nowTick = 72_000_000;
await expect( 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: '경매 마감 세대가 변경되었습니다.' }); ).resolves.toMatchObject({ ok: false, reason: '경매 마감 세대가 변경되었습니다.' });
expect(executeRaw).not.toHaveBeenCalled(); expect(executeRaw).not.toHaveBeenCalled();
@@ -276,12 +297,16 @@ describe('unique auction inheritance log compatibility', () => {
detail: { amount: 100 }, detail: { amount: 100 },
status: 'OPEN', status: 'OPEN',
closeAt, closeAt,
closeTick: null, closeTick: 72_000_000n,
}, },
]; ];
}); });
const commandDb = { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 0) }; 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({ const finalizer = await createAuctionFinalizer({
databaseUrl: 'postgresql://unused', databaseUrl: 'postgresql://unused',
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'], world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
@@ -289,7 +314,12 @@ describe('unique auction inheritance log compatibility', () => {
await expect( await expect(
finalizer.finalize( 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]> commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
) )
).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31'); ).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31');
@@ -317,6 +347,7 @@ describe('unique auction inheritance log compatibility', () => {
const queueMessage = vi.fn(); const queueMessage = vi.fn();
const world = { const world = {
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'), 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), getGeneralById: (id: number) => (id === bidder.id ? bidder : id === host.id ? host : null),
getNationById: () => ({ name: '촉', color: '#ff0000' }), getNationById: () => ({ name: '촉', color: '#ff0000' }),
updateGeneral, updateGeneral,
@@ -350,7 +381,7 @@ describe('unique auction inheritance log compatibility', () => {
await expect( await expect(
finalizer.finalize( 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]> commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
) )
).resolves.toMatchObject({ ).resolves.toMatchObject({
@@ -79,6 +79,7 @@ describeIntegration('durable clock reconciliation', () => {
revision: 1, revision: 1,
}); });
const generalTicks = [initialTick + 1_234, initialTick + 36_000_123]; const generalTicks = [initialTick + 1_234, initialTick + 36_000_123];
const reselectionTick = initialTick + 54_000_456;
const auctionCloseTick = initialTick + 72_000_777; const auctionCloseTick = initialTick + 72_000_777;
const messageOccurrenceTick = initialTick - 500; const messageOccurrenceTick = initialTick - 500;
const messageExpiryTick = initialTick + 90_000_999; const messageExpiryTick = initialTick + 90_000_999;
@@ -116,6 +117,14 @@ describeIntegration('durable clock reconciliation', () => {
turnTime: clock.tickToDate(turnTick), turnTime: clock.tickToDate(turnTick),
recentWarTick: BigInt(initialTick - 100 - index), recentWarTick: BigInt(initialTick - 100 - index),
recentWarTime: clock.tickToDate(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({ await db.auction.create({
@@ -138,7 +147,20 @@ describeIntegration('durable clock reconciliation', () => {
timeTick: BigInt(messageOccurrenceTick), timeTick: BigInt(messageOccurrenceTick),
validUntil: clock.tickToDate(messageExpiryTick), validUntil: clock.tickToDate(messageExpiryTick),
validUntilTick: BigInt(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: {}, message: {},
action: {
create: {
actionType: 'scout',
status: 'PENDING',
createdGameTick: BigInt(messageOccurrenceTick),
expiresGameTick: BigInt(messageExpiryTick),
clockRevision: 1n,
deadlineGeneration: 7n,
},
},
}, },
}); });
await db.votePoll.create({ await db.votePoll.create({
@@ -201,11 +223,13 @@ describeIntegration('durable clock reconciliation', () => {
alignedTick: 236_035_000, alignedTick: 236_035_000,
}); });
const [afterWorld, generals, auction, message, vote, pool, token, ledger, outboxes] = await Promise.all([ const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
await Promise.all([
db.worldState.findUniqueOrThrow({ where: { id: world.id } }), db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
db.general.findMany({ orderBy: { id: 'asc' } }), db.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(), db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(), db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(), db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(), db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(), db.npcSelectionToken.findFirstOrThrow(),
@@ -223,8 +247,19 @@ describeIntegration('durable clock reconciliation', () => {
expect(generals.map((general) => general.turnTick! - alignedTick)).toEqual( expect(generals.map((general) => general.turnTick! - alignedTick)).toEqual(
generalTicks.map((tick) => BigInt(tick - initialTick)) 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(auction.closeTick! - alignedTick).toBe(BigInt(auctionCloseTick - initialTick));
expect(message.validUntilTick! - alignedTick).toBe(BigInt(messageExpiryTick - 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(vote.endTick! - alignedTick).toBe(BigInt(voteEndTick - initialTick));
expect(pool.reservedUntilTick! - alignedTick).toBe(BigInt(poolTick - initialTick)); expect(pool.reservedUntilTick! - alignedTick).toBe(BigInt(poolTick - initialTick));
expect(token.validUntilTick! - alignedTick).toBe(BigInt(npcValidTick - initialTick)); expect(token.validUntilTick! - alignedTick).toBe(BigInt(npcValidTick - initialTick));
@@ -302,6 +337,21 @@ describeIntegration('durable clock reconciliation', () => {
await db.general.create({ await db.general.create({
data: { id: 1, name: 'day-general', turnTick: BigInt(turnTick), turnTime: clock.tickToDate(turnTick) }, 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({ await db.turnDaemonLease.create({
data: { data: {
profile: 'clock-day-test', profile: 'clock-day-test',
@@ -346,6 +396,10 @@ describeIntegration('durable clock reconciliation', () => {
}); });
const shifted = await db.general.findUniqueOrThrow({ where: { id: 1 } }); const shifted = await db.general.findUniqueOrThrow({ where: { id: 1 } });
expect(shifted.turnTick! - BigInt(reconciled.alignedTick)).toBe(BigInt(turnTick - initialTick)); 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'); await redis.client.set('sammo:clock-day-test:clock:active-revision', '3');
const redisThenCrash = { const redisThenCrash = {
@@ -25,7 +25,26 @@ integration('database command queue', () => {
}); });
await db.message.deleteMany({ where: { mailbox: 991_199 } }); await db.message.deleteMany({ where: { mailbox: 991_199 } });
await db.worldState.deleteMany({ await db.worldState.deleteMany({
where: { scenarioCode: { in: ['queue-clock-test', 'queue-unification-clock-test'] } }, 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,
},
}); });
}; };
@@ -39,7 +58,10 @@ integration('database command queue', () => {
}); });
}); });
beforeEach(cleanupFixtures); beforeEach(async () => {
await cleanupFixtures();
await createClockFixture();
});
afterAll(async () => { afterAll(async () => {
await cleanupFixtures(); await cleanupFixtures();
@@ -63,7 +85,16 @@ integration('database command queue', () => {
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]); const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
const commands = firstCommands.concat(secondCommands); 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 }); await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
@@ -118,7 +149,16 @@ integration('database command queue', () => {
await queue.initialize(); await queue.initialize();
const commands = await queue.drain(); 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({ expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
status: 'PROCESSING', status: 'PROCESSING',
lockedBy: 'active-worker', lockedBy: 'active-worker',
@@ -146,7 +186,14 @@ integration('database command queue', () => {
const stale = new DatabaseTurnDaemonCommandQueue(db); const stale = new DatabaseTurnDaemonCommandQueue(db);
for (const attempt of [1, 2, 3]) { for (const attempt of [1, 2, 3]) {
await expect(owner.drain()).resolves.toEqual([ 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 stale.publishCommandError(requestId, new Error('stale worker failure'));
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
@@ -220,7 +267,13 @@ integration('database command queue', () => {
const owner = new DatabaseTurnDaemonCommandQueue(db); const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain(); 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 })); const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({ expect(result).toMatchObject({
type: 'commandRejected', type: 'commandRejected',
@@ -301,7 +354,14 @@ integration('database command queue', () => {
}); });
const queue = new DatabaseTurnDaemonCommandQueue(db); const queue = new DatabaseTurnDaemonCommandQueue(db);
expect(await queue.drain()).toEqual([{ type: 'getStatus', requestId: statusId }]); expect(await queue.drain()).toEqual([
{
type: 'getStatus',
requestId: statusId,
processingGameTick: 100,
requestedAtWall: expect.any(Date),
},
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({ expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
status: 'PENDING', status: 'PENDING',
processingClockRevision: null, processingClockRevision: null,
@@ -309,7 +369,14 @@ integration('database command queue', () => {
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } }); await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } });
expect(await queue.drain()).toEqual([ expect(await queue.drain()).toEqual([
{ type: 'vacation', requestId: gameplayId, userId: 'user-7', generalId: 7 }, {
type: 'vacation',
requestId: gameplayId,
userId: 'user-7',
generalId: 7,
processingGameTick: 100,
requestedAtWall: expect.any(Date),
},
]); ]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({ expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
status: 'PROCESSING', status: 'PROCESSING',
@@ -347,6 +414,7 @@ integration('database command queue', () => {
userId: 'user-8', userId: 'user-8',
generalId: 8, generalId: 8,
processingGameTick: 123, processingGameTick: 123,
requestedAtWall: expect.any(Date),
}, },
]); ]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({ expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({
@@ -359,6 +427,103 @@ integration('database command queue', () => {
}); });
}); });
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 only the invader decision while an UNIFICATION_WAIT suspension is active', async () => { it('dequeues only the invader decision while an UNIFICATION_WAIT suspension is active', async () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } }); const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld const world = existingWorld
@@ -389,6 +554,37 @@ integration('database command queue', () => {
message: { option: { action: 'raiseInvader', used: false } }, 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({ await db.clockSuspension.create({
data: { data: {
id: 'integration-unification-wait', id: 'integration-unification-wait',
@@ -404,6 +600,7 @@ integration('database command queue', () => {
}, },
}); });
const messageRequestId = 'integration:engine:unification-message'; const messageRequestId = 'integration:engine:unification-message';
const scoutRequestId = 'integration:engine:suspended-scout-response';
const gameplayRequestId = 'integration:engine:unification-gameplay'; const gameplayRequestId = 'integration:engine:unification-gameplay';
await db.inputEvent.createMany({ await db.inputEvent.createMany({
data: [ data: [
@@ -424,6 +621,23 @@ integration('database command queue', () => {
response: true, 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, requestId: gameplayRequestId,
target: 'ENGINE', target: 'ENGINE',
@@ -451,10 +665,15 @@ integration('database command queue', () => {
generalId: 991_199, generalId: 991_199,
messageId: message.id, messageId: message.id,
response: true, response: true,
processingGameTick: 900,
requestedAtWall: expect.any(Date),
}, },
]); ]);
await expect( await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } }) db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } })
).resolves.toMatchObject({ status: 'PENDING' }); ).resolves.toMatchObject({ status: 'PENDING' });
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: scoutRequestId } })).resolves.toMatchObject({
status: 'PENDING',
});
}); });
}); });
@@ -165,6 +165,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
db = connector.prisma; db = connector.prisma;
disconnect = () => connector.disconnect(); disconnect = () => connector.disconnect();
await dropFailureConstraints(); await dropFailureConstraints();
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } }); await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
@@ -198,6 +199,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
await hooks?.close(); await hooks?.close();
if (db) { if (db) {
await dropFailureConstraints(); await dropFailureConstraints();
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } }); await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
@@ -284,7 +286,13 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
}, },
}); });
}; };
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( await expect(
db.inheritancePoint.findUniqueOrThrow({ db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: actorUserId, key: 'previous' } }, where: { userId_key: { userId: actorUserId, key: 'previous' } },
@@ -299,6 +307,9 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
await expect( await expect(
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } }) db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
).resolves.toBe(messageCount); ).resolves.toBe(messageCount);
await expect(
db.inheritanceLedger.count({ where: { requestId: { startsWith: requestPrefix } } })
).resolves.toBe(ledgerCount);
}; };
const pointCommand = buildCommand('point', { const pointCommand = buildCommand('point', {
@@ -315,7 +326,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`); 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).toMatchObject({ inherit_spent_dyn: 17 });
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff'); 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( await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } }) db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
).resolves.toMatchObject({ ).resolves.toMatchObject({
@@ -324,7 +335,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
}); });
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`); await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 }); 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 }); const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
await createInputEvent(rankCommand); await createInputEvent(rankCommand);
@@ -335,14 +346,14 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
`); `);
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`); await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
expect(world.peekDirtyState().messages).toEqual([]); 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 db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
await expect(execute(rankCommand)).resolves.toMatchObject({ await expect(execute(rankCommand)).resolves.toMatchObject({
ok: true, ok: true,
remainPoint: 8_800, remainPoint: 8_800,
ownerName: '레거시 소유자', ownerName: '레거시 소유자',
}); });
await assertStored(8_800, 1_217, 2, 2); await assertStored(8_800, 1_217, 2, 2, 2);
const currentLog = await db.inheritanceLog.findFirstOrThrow({ const currentLog = await db.inheritanceLog.findFirstOrThrow({
where: { userId: actorUserId }, where: { userId: actorUserId },
@@ -358,10 +369,10 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
`); `);
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`); await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique'); 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 db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 }); 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', { const freeStatCommand = buildCommand('free-stat', {
action: 'resetStat', action: 'resetStat',
@@ -372,7 +383,21 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
}); });
await createInputEvent(freeStatCommand); await createInputEvent(freeStatCommand);
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 }); 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({ const messages = await db.message.findMany({
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } }, where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
@@ -133,6 +133,7 @@ describe('input event atomicity', () => {
ok: true, ok: true,
auctionId: 3, auctionId: 3,
closeAt: '2026-01-01T00:10:00.000Z', closeAt: '2026-01-01T00:10:00.000Z',
closeTick: 3_600_000,
}; };
let resolveResponse: (() => void) | undefined; let resolveResponse: (() => void) | undefined;
const responded = new Promise<void>((resolve) => { const responded = new Promise<void>((resolve) => {
@@ -806,6 +806,8 @@ describeDb('scenario database seed', () => {
amount: 1, amount: 1,
eventId: marker, eventId: marker,
eventAt: new Date('2033-01-01T00:00:00.000Z'), eventAt: new Date('2033-01-01T00:00:00.000Z'),
occurredGameTick: 0n,
requestedAtWall: new Date('2033-01-01T00:00:00.000Z'),
}, },
}); });
const bettingId = 990_731; const bettingId = 990_731;
@@ -224,7 +224,7 @@ describe('selection-pool reservation command state', () => {
const rows = buildRows(); const rows = buildRows();
const world = buildWorld(rows); const world = buildWorld(rows);
const db = buildDb(rows); const db = buildDb(rows);
const reserve = (userId: string, acceptedGameTick: number) => const reserve = (userId: string, processingGameTick: number) =>
reserveSelectionPool({ reserveSelectionPool({
db: db as never, db: db as never,
world, world,
@@ -232,7 +232,7 @@ describe('selection-pool reservation command state', () => {
userId, userId,
seedOwnerIdentity: userId, seedOwnerIdentity: userId,
now: acceptedAt, now: acceptedAt,
acceptedGameTick, processingGameTick,
}); });
const first = await reserve('first-user', 0); const first = await reserve('first-user', 0);
@@ -267,7 +267,7 @@ describe('selection-pool reservation command state', () => {
rows[1]!.reservedUntilTick = 0n; rows[1]!.reservedUntilTick = 0n;
const world = buildWorld(rows); const world = buildWorld(rows);
const db = buildDb(rows); const db = buildDb(rows);
const reserve = (userId: string, acceptedGameTick: number) => const reserve = (userId: string, processingGameTick: number) =>
reserveSelectionPool({ reserveSelectionPool({
db: db as never, db: db as never,
world, world,
@@ -275,7 +275,7 @@ describe('selection-pool reservation command state', () => {
userId, userId,
seedOwnerIdentity: userId, seedOwnerIdentity: userId,
now: acceptedAt, now: acceptedAt,
acceptedGameTick, processingGameTick,
}); });
const first = await reserve('first-user', 0); const first = await reserve('first-user', 0);
+14 -45
View File
@@ -12,7 +12,6 @@ import {
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js'; import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js';
@@ -81,38 +80,12 @@ const buildDefaultUniquePoolSnapshot = (general: TurnGeneral): TurnWorldSnapshot
}); });
describe('voteReward command', () => { describe('voteReward command', () => {
it('keeps the wall-time fallback open at exact deadline equality', () => { it('fails closed when a GAME_TIME poll lost its authoritative end tick', () => {
const deadline = new Date('0180-01-01T00:00:00.000Z'); const deadline = new Date('0180-01-01T00:00:00.000Z');
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, deadline, 0)).toBe(false); expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, 0)).toBe(true);
expect( expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 0)).toBe(false);
hasVotePollDeadlinePassed( expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 1)).toBe(true);
{ endAt: deadline, endTick: null, closedAt: null },
new Date(deadline.getTime() + 1),
0
)
).toBe(true);
});
it('preserves the server-accepted game tick through durable command normalization', () => {
expect(
normalizeTurnDaemonCommand({
requestId: 'vote-accepted-tick',
sentAt: '2026-08-23T00:00:00.000Z',
command: {
type: 'voteReward',
userId: 'user-1',
voteId: 1,
generalId: 1,
selection: [0],
acceptedGameTick: 100,
},
})
).toMatchObject({
type: 'voteReward',
requestId: 'vote-accepted-tick',
acceptedGameTick: 100,
});
}); });
it('applies gold, unique item, logs, and idempotency', async () => { it('applies gold, unique item, logs, and idempotency', async () => {
@@ -290,9 +263,7 @@ describe('voteReward command', () => {
voteId: 1, voteId: 1,
generalId: 1, generalId: 1,
selection: [0], selection: [0],
// Ref accepts the request at exact equality. Engine processing may processingGameTick: 0,
// occur after the logical clock has advanced beyond the deadline.
acceptedGameTick: 0,
}; };
const writerWindowStart = Date.now(); const writerWindowStart = Date.now();
@@ -418,8 +389,9 @@ describe('voteReward command', () => {
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
); );
const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld }); const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld });
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command; const { processingGameTick: _processingGameTick, ...missingBoundaryCommand } = command;
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, { await expect(
legacyLateHandler.handle(missingBoundaryCommand, {
db: { db: {
...actorBindingDb(), ...actorBindingDb(),
$queryRaw: async (query: { strings: readonly string[] }) => $queryRaw: async (query: { strings: readonly string[] }) =>
@@ -435,12 +407,8 @@ describe('voteReward command', () => {
] ]
: [], : [],
} as any, } as any,
}); })
expect(legacyLateResult).toMatchObject({ ).rejects.toThrow('authoritative daemon processing game tick');
type: 'voteReward',
ok: false,
reason: '설문조사가 종료되었습니다.',
});
}); });
it.each([ it.each([
@@ -501,8 +469,8 @@ describe('voteReward command', () => {
voteId, voteId,
generalId: 1, generalId: 1,
selection: [0], selection: [0],
acceptedGameTick, processingGameTick: acceptedGameTick,
}, } as any,
{ db: commandDb as any } { db: commandDb as any }
); );
@@ -602,7 +570,8 @@ describe('voteReward command', () => {
voteId: 1, voteId: 1,
generalId: 1, generalId: 1,
selection: [0], selection: [0],
}, processingGameTick: 0,
} as any,
{ db: commandDb as any } { db: commandDb as any }
); );
@@ -12,7 +12,7 @@ export const createGameServerActivityTracker = (): GameServerActivityTracker =>
return { return {
lastContactAt: readonly(lastContactAt), lastContactAt: readonly(lastContactAt),
markContact(contactAt = Date.now()) { markContact(contactAt = performance.now()) {
if (!Number.isFinite(contactAt)) return; if (!Number.isFinite(contactAt)) return;
lastContactAt.value = contactAt; lastContactAt.value = contactAt;
}, },
@@ -21,7 +21,7 @@ export const createGameServerActivityTracker = (): GameServerActivityTracker =>
export const isRecentGameServerActivity = ( export const isRecentGameServerActivity = (
lastContactAt: number | null, lastContactAt: number | null,
now = Date.now(), now = performance.now(),
freshnessMs = GAME_SERVER_ACTIVITY_FRESHNESS_MS freshnessMs = GAME_SERVER_ACTIVITY_FRESHNESS_MS
): boolean => ): boolean =>
lastContactAt !== null && lastContactAt !== null &&
@@ -31,4 +31,4 @@ export const isRecentGameServerActivity = (
export const gameServerActivity = createGameServerActivityTracker(); export const gameServerActivity = createGameServerActivityTracker();
export const markGameServerContact = (contactAt = Date.now()) => gameServerActivity.markContact(contactAt); export const markGameServerContact = (contactAt = performance.now()) => gameServerActivity.markContact(contactAt);
+6 -6
View File
@@ -1260,7 +1260,7 @@ export const adminRouter = router({
if (!canReadProfile(adminAuth, initialOperation.profileName)) { if (!canReadProfile(adminAuth, initialOperation.profileName)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' }); throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' });
} }
const deadline = Date.now() + input.timeoutMs; const deadline = performance.now() + input.timeoutMs;
while (true) { while (true) {
const [operation, entries] = await Promise.all([ const [operation, entries] = await Promise.all([
ctx.profiles.getOperation(input.id), ctx.profiles.getOperation(input.id),
@@ -1270,7 +1270,7 @@ export const adminRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' });
} }
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status); const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
if (entries.length || terminal || Date.now() >= deadline) { if (entries.length || terminal || performance.now() >= deadline) {
return { return {
operation, operation,
entries, entries,
@@ -1942,7 +1942,7 @@ export const adminRouter = router({
}) })
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const deadline = Date.now() + input.timeoutMs; const deadline = performance.now() + input.timeoutMs;
while (true) { while (true) {
const [operation, entries] = await Promise.all([ const [operation, entries] = await Promise.all([
ctx.releases.getOperation(input.id), ctx.releases.getOperation(input.id),
@@ -1952,7 +1952,7 @@ export const adminRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Gateway release operation not found.' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Gateway release operation not found.' });
} }
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status); const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
if (entries.length || terminal || Date.now() >= deadline) { if (entries.length || terminal || performance.now() >= deadline) {
return { return {
operation, operation,
entries, entries,
@@ -2519,8 +2519,8 @@ export const adminRouter = router({
reason: input.reason, reason: input.reason,
requestedBy: adminAuth.user.id, requestedBy: adminAuth.user.id,
}); });
const deadline = Date.now() + 10 * 60_000; const deadline = performance.now() + 10 * 60_000;
while (Date.now() < deadline) { while (performance.now() < deadline) {
await ctx.orchestrator.runOperationsNow(); await ctx.orchestrator.runOperationsNow();
const current = await ctx.profiles.getOperation(operation.id); const current = await ctx.profiles.getOperation(operation.id);
if (current?.status === 'SUCCEEDED') { if (current?.status === 'SUCCEEDED') {
@@ -2995,7 +2995,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
profile: GatewayProfileRecord, profile: GatewayProfileRecord,
assertLease?: () => Promise<void> assertLease?: () => Promise<void>
): Promise<boolean> { ): Promise<boolean> {
const deadline = Date.now() + this.profileReadinessTimeoutMs; const deadline = performance.now() + this.profileReadinessTimeoutMs;
const definitions = buildProcessDefinitions(profile, this.processConfig); const definitions = buildProcessDefinitions(profile, this.processConfig);
const expectedNames = Object.entries(definitions) const expectedNames = Object.entries(definitions)
.filter(([role]) => this.frontendServeMode === 'preview' || role !== 'frontend') .filter(([role]) => this.frontendServeMode === 'preview' || role !== 'frontend')
@@ -3008,7 +3008,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.processConfig.frontendReadinessOrigin ?? 'http://caddy' this.processConfig.frontendReadinessOrigin ?? 'http://caddy'
).toString() ).toString()
: `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`; : `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`;
while (Date.now() < deadline) { while (performance.now() < deadline) {
await assertLease?.(); await assertLease?.();
try { try {
const [api, frontend, processes] = await Promise.all([ const [api, frontend, processes] = await Promise.all([
@@ -242,13 +242,19 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
}); });
return mapOperation(row); return mapOperation(row);
}, },
async claimNextOperation(now, lease) { async claimNextOperation(_now, lease) {
const row = await prisma.$transaction(async (tx) => { const row = await prisma.$transaction(async (tx) => {
await tx.$queryRaw<Array<{ lock_result: string }>>` await tx.$queryRaw<Array<{ lock_result: string }>>`
SELECT pg_advisory_xact_lock( SELECT pg_advisory_xact_lock(
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0) hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
)::text AS lock_result )::text AS lock_result
`; `;
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`
SELECT CURRENT_TIMESTAMP AS "now"
`;
if (!now) {
throw new Error('Database wall clock is unavailable while claiming a Gateway release operation.');
}
const runningProfileOperation = await tx.gatewayOperation.findFirst({ const runningProfileOperation = await tx.gatewayOperation.findFirst({
where: { status: 'RUNNING' }, where: { status: 'RUNNING' },
select: { id: true }, select: { id: true },
@@ -326,14 +332,22 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
}); });
return row ? mapOperation(row) : null; return row ? mapOperation(row) : null;
}, },
async renewOperationLease(id, ownerId, now, durationMs) { async renewOperationLease(id, ownerId, _now, durationMs) {
const updated = await prisma.gatewayReleaseOperation.updateMany({ const updated = await prisma.$transaction(async (tx) => {
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`
SELECT CURRENT_TIMESTAMP AS "now"
`;
if (!now) {
throw new Error('Database wall clock is unavailable while renewing a Gateway release lease.');
}
return tx.gatewayReleaseOperation.updateMany({
where: { id, status: 'RUNNING', leaseOwner: ownerId }, where: { id, status: 'RUNNING', leaseOwner: ownerId },
data: { data: {
leaseUntil: new Date(now.getTime() + durationMs), leaseUntil: new Date(now.getTime() + durationMs),
heartbeatAt: now, heartbeatAt: now,
}, },
}); });
});
return updated.count === 1; return updated.count === 1;
}, },
async pinOperationResolvedCommit(id, ownerId, resolvedCommitSha) { async pinOperationResolvedCommit(id, ownerId, resolvedCommitSha) {
@@ -691,7 +691,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return mapOperation(row); return mapOperation(row);
}, },
async claimNextOperation( async claimNextOperation(
now: Date, _now: Date,
lease?: { ownerId: string; durationMs: number } lease?: { ownerId: string; durationMs: number }
): Promise<GatewayOperationRecord | null> { ): Promise<GatewayOperationRecord | null> {
const row = await prisma.$transaction(async (tx) => { const row = await prisma.$transaction(async (tx) => {
@@ -700,6 +700,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0) hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
)::text AS lock_result )::text AS lock_result
`; `;
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`
SELECT CURRENT_TIMESTAMP AS "now"
`;
if (!now) {
throw new Error('Database wall clock is unavailable while claiming a Gateway operation.');
}
const runningRelease = await tx.gatewayReleaseOperation.findFirst({ const runningRelease = await tx.gatewayReleaseOperation.findFirst({
where: { status: 'RUNNING' }, where: { status: 'RUNNING' },
select: { id: true }, select: { id: true },
@@ -815,14 +821,22 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
}); });
return row ? mapOperation(row) : null; return row ? mapOperation(row) : null;
}, },
async renewOperationLease(id: string, ownerId: string, now: Date, durationMs: number): Promise<boolean> { async renewOperationLease(id: string, ownerId: string, _now: Date, durationMs: number): Promise<boolean> {
const renewed = await prisma.gatewayOperation.updateMany({ const renewed = await prisma.$transaction(async (tx) => {
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`
SELECT CURRENT_TIMESTAMP AS "now"
`;
if (!now) {
throw new Error('Database wall clock is unavailable while renewing a Gateway operation lease.');
}
return tx.gatewayOperation.updateMany({
where: { id, status: 'RUNNING', leaseOwner: ownerId }, where: { id, status: 'RUNNING', leaseOwner: ownerId },
data: { data: {
leaseUntil: new Date(now.getTime() + durationMs), leaseUntil: new Date(now.getTime() + durationMs),
heartbeatAt: now, heartbeatAt: now,
}, },
}); });
});
return renewed.count === 1; return renewed.count === 1;
}, },
async pinOperationResolvedCommit(id: string, ownerId: string, resolvedCommitSha: string): Promise<boolean> { async pinOperationResolvedCommit(id: string, ownerId: string, resolvedCommitSha: string): Promise<boolean> {
@@ -293,13 +293,13 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null })
} }
if (!gitRef) { if (!gitRef) {
const cached = previewCache.get(DEFAULT_CACHE_KEY); const cached = previewCache.get(DEFAULT_CACHE_KEY);
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) { if (cached && performance.now() - cached.loadedAt < CACHE_TTL_MS) {
return cached.data; return cached.data;
} }
const ids = await listScenarioIds(); const ids = await listScenarioIds();
const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id))); const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id)));
previewCache.set(DEFAULT_CACHE_KEY, { previewCache.set(DEFAULT_CACHE_KEY, {
loadedAt: Date.now(), loadedAt: performance.now(),
data: previews, data: previews,
}); });
return previews; return previews;
@@ -308,7 +308,7 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null })
const commitSha = await resolveGitCommitSha(gitRef); const commitSha = await resolveGitCommitSha(gitRef);
const cacheKey = commitSha; const cacheKey = commitSha;
const cached = previewCache.get(cacheKey); const cached = previewCache.get(cacheKey);
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) { if (cached && performance.now() - cached.loadedAt < CACHE_TTL_MS) {
return cached.data; return cached.data;
} }
const ids = await listScenarioIdsFromGit(commitSha); const ids = await listScenarioIdsFromGit(commitSha);
@@ -317,7 +317,7 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null })
previews.push(await buildScenarioPreviewFromGit(commitSha, id)); previews.push(await buildScenarioPreviewFromGit(commitSha, id));
} }
previewCache.set(cacheKey, { previewCache.set(cacheKey, {
loadedAt: Date.now(), loadedAt: performance.now(),
data: previews, data: previews,
}); });
return previews; return previews;
+61 -46
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { import {
WEB_PUSH_EVENT_TYPES, WEB_PUSH_EVENT_TYPES,
@@ -409,10 +410,13 @@ export class WebPushCoordinator {
`); `);
if (rows.length === 0) return []; if (rows.length === 0) return [];
const ids = rows.map((row) => row.id); const ids = rows.map((row) => row.id);
await tx.webPushDelivery.updateMany({ await tx.$executeRaw(GatewayPrisma.sql`
where: { id: { in: ids } }, UPDATE web_push_delivery
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } }, SET locked_at = CURRENT_TIMESTAMP,
}); lock_owner = ${this.owner},
attempts = attempts + 1
WHERE id IN (${GatewayPrisma.join(ids)})
`);
return tx.webPushDelivery.findMany({ return tx.webPushDelivery.findMany({
where: { id: { in: ids }, lockOwner: this.owner }, where: { id: { in: ids }, lockOwner: this.owner },
include: { notification: true, subscription: true }, include: { notification: true, subscription: true },
@@ -421,22 +425,31 @@ export class WebPushCoordinator {
}); });
for (const delivery of claimed) { for (const delivery of claimed) {
if (delivery.subscription.expirationTime && delivery.subscription.expirationTime.getTime() <= Date.now()) { const expired = await this.prisma.$transaction(async (tx) => {
await this.prisma.$transaction(async (tx) => { const count = await tx.$executeRaw(GatewayPrisma.sql`
await tx.webPushDelivery.updateMany({ UPDATE web_push_delivery AS delivery
where: { id: delivery.id, lockOwner: this.owner }, SET status = 'FAILED'::"WebPushDeliveryStatus",
data: { locked_at = NULL,
status: 'FAILED', lock_owner = NULL,
lockedAt: null, last_error = 'Push subscription expired.'
lockOwner: null, FROM web_push_subscription AS subscription
lastError: 'Push subscription expired.', WHERE delivery.id = ${delivery.id}
}, AND delivery.lock_owner = ${this.owner}
}); AND subscription.id = delivery.subscription_id
await tx.webPushSubscription.update({ AND subscription.expiration_time IS NOT NULL
where: { id: delivery.subscriptionId }, AND subscription.expiration_time <= CURRENT_TIMESTAMP
data: { disabledAt: new Date() }, `);
}); if (count > 0) {
await tx.$executeRaw(GatewayPrisma.sql`
UPDATE web_push_subscription
SET disabled_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ${delivery.subscriptionId}
`);
}
return count > 0;
}); });
if (expired) {
continue; continue;
} }
try { try {
@@ -453,16 +466,15 @@ export class WebPushCoordinator {
}), }),
{ TTL: 60 * 60 } { TTL: 60 * 60 }
); );
await this.prisma.webPushDelivery.updateMany({ await this.prisma.$executeRaw(GatewayPrisma.sql`
where: { id: delivery.id, lockOwner: this.owner }, UPDATE web_push_delivery
data: { SET status = 'DELIVERED'::"WebPushDeliveryStatus",
status: 'DELIVERED', delivered_at = CURRENT_TIMESTAMP,
deliveredAt: new Date(), locked_at = NULL,
lockedAt: null, lock_owner = NULL,
lockOwner: null, last_error = NULL
lastError: null, WHERE id = ${delivery.id} AND lock_owner = ${this.owner}
}, `);
});
} catch (error) { } catch (error) {
const statusCode = const statusCode =
typeof error === 'object' && error !== null && 'statusCode' in error typeof error === 'object' && error !== null && 'statusCode' in error
@@ -478,28 +490,31 @@ export class WebPushCoordinator {
const safeError = const safeError =
statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.'; statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.webPushDelivery.updateMany({ const nextStatus = terminal || exhausted ? 'FAILED' : 'PENDING';
where: { id: delivery.id, lockOwner: this.owner }, await tx.$executeRaw(GatewayPrisma.sql`
data: { UPDATE web_push_delivery
status: terminal || exhausted ? 'FAILED' : 'PENDING', SET status = ${nextStatus}::"WebPushDeliveryStatus",
availableAt: new Date(Date.now() + delaySeconds * 1_000), available_at = CURRENT_TIMESTAMP
lockedAt: null, + ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
lockOwner: null, locked_at = NULL,
lastError: safeError, lock_owner = NULL,
}, last_error = ${safeError}
}); WHERE id = ${delivery.id} AND lock_owner = ${this.owner}
`);
if (statusCode === 404 || statusCode === 410) { if (statusCode === 404 || statusCode === 410) {
await tx.webPushSubscription.update({ await tx.$executeRaw(GatewayPrisma.sql`
where: { id: delivery.subscriptionId }, UPDATE web_push_subscription
data: { disabledAt: new Date() }, SET disabled_at = CURRENT_TIMESTAMP,
}); updated_at = CURRENT_TIMESTAMP
WHERE id = ${delivery.subscriptionId}
`);
} }
}); });
if (!terminal) this.onError(new Error(safeError)); if (!terminal) this.onError(new Error(safeError));
} }
} }
if (Date.now() >= this.nextPruneAt) { if (performance.now() >= this.nextPruneAt) {
this.nextPruneAt = Date.now() + 60_000; this.nextPruneAt = performance.now() + 60_000;
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw(GatewayPrisma.sql` await tx.$executeRaw(GatewayPrisma.sql`
WITH expired AS ( WITH expired AS (
@@ -534,7 +549,7 @@ export class WebPushCoordinator {
private run(): void { private run(): void {
if (!this.configured || this.inFlight) return; if (!this.configured || this.inFlight) return;
const now = Date.now(); const now = performance.now();
const shouldReconcileProfiles = now >= this.nextProfileReconcileAt; const shouldReconcileProfiles = now >= this.nextProfileReconcileAt;
if (shouldReconcileProfiles) this.nextProfileReconcileAt = now + 5_000; if (shouldReconcileProfiles) this.nextProfileReconcileAt = now + 5_000;
this.inFlight = (shouldReconcileProfiles ? this.reconcileProfiles() : Promise.resolve()) this.inFlight = (shouldReconcileProfiles ? this.reconcileProfiles() : Promise.resolve())
@@ -41,13 +41,16 @@ describeDatabase('gateway release operation persistence', () => {
).rejects.toMatchObject({ code: 'P2002' }); ).rejects.toMatchObject({ code: 'P2002' });
const now = new Date('2030-01-01T00:00:00.000Z'); const now = new Date('2030-01-01T00:00:00.000Z');
await expect( const claimed = await repository.claimNextOperation(now, {
repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 }) ownerId: 'controller-a',
).resolves.toMatchObject({ durationMs: 1_000,
});
expect(claimed).toMatchObject({
id: operation.id, id: operation.id,
attempts: 1, attempts: 1,
leaseOwner: 'controller-a', leaseOwner: 'controller-a',
}); });
expect(Date.parse(claimed?.leaseUntil ?? '')).toBeLessThan(Date.now() + 5_000);
await expect(repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'a'.repeat(40))).resolves.toBe( await expect(repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'a'.repeat(40))).resolves.toBe(
true true
); );
@@ -103,6 +106,11 @@ describeDatabase('gateway release operation persistence', () => {
await repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 }); await repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 });
await repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'c'.repeat(40)); await repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'c'.repeat(40));
await connector.prisma.$executeRaw`
UPDATE "gateway_release_operation"
SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second'
WHERE "id" = ${operation.id}
`;
await expect( await expect(
repository.claimNextOperation(new Date(now.getTime() + 1_001), { repository.claimNextOperation(new Date(now.getTime() + 1_001), {
ownerId: 'controller-b', ownerId: 'controller-b',
@@ -114,6 +114,11 @@ describeDatabase('gateway operation lease and profile serialization', () => {
await expect( await expect(
repository.claimNextOperation(beforeReset, { ownerId: 'worker-b', durationMs: 1_000 }) repository.claimNextOperation(beforeReset, { ownerId: 'worker-b', durationMs: 1_000 })
).resolves.toBeNull(); ).resolves.toBeNull();
await connector.prisma.$executeRaw`
UPDATE "gateway_operation"
SET "scheduled_at" = CURRENT_TIMESTAMP - INTERVAL '1 second'
WHERE "id" = ${scheduledReset.id}
`;
await expect( await expect(
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-b', durationMs: 1_000 }) repository.claimNextOperation(scheduledAt, { ownerId: 'worker-b', durationMs: 1_000 })
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' }); ).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
@@ -168,13 +173,18 @@ describeDatabase('gateway operation lease and profile serialization', () => {
sourceRef: 'b'.repeat(40), sourceRef: 'b'.repeat(40),
requestedBy: 'deploy-admin', requestedBy: 'deploy-admin',
}); });
await connector.prisma.$executeRaw`
UPDATE "gateway_operation"
SET "scheduled_at" = CURRENT_TIMESTAMP - INTERVAL '1 second'
WHERE "id" = ${scheduledReset.id}
`;
await expect( await expect(
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-a', durationMs: 1_000 }) repository.claimNextOperation(scheduledAt, { ownerId: 'worker-a', durationMs: 1_000 })
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' }); ).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
await expect(repository.getOperation(interimDeploy.id)).resolves.toMatchObject({ await expect(repository.getOperation(interimDeploy.id)).resolves.toMatchObject({
status: 'CANCELLED', status: 'CANCELLED',
completedAt: scheduledAt.toISOString(), completedAt: expect.any(String),
}); });
await expect(repository.listOperationLogs(interimDeploy.id)).resolves.toEqual([ await expect(repository.listOperationLogs(interimDeploy.id)).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({
@@ -405,6 +415,11 @@ describeDatabase('gateway operation lease and profile serialization', () => {
durationMs: 1_000, durationMs: 1_000,
}) })
).resolves.toBeNull(); ).resolves.toBeNull();
await connector.prisma.$executeRaw`
UPDATE "gateway_operation"
SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second'
WHERE "id" = ${operation.id}
`;
const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), { const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), {
ownerId: 'worker-b', ownerId: 'worker-b',
durationMs: 1_000, durationMs: 1_000,
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches', gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
gameSchemaHead: '20260903103000_add_input_event_clock_processing', gameSchemaHead: '20260903140000_split_message_wall_and_game_time',
}); });
}); });
@@ -14,13 +14,13 @@ let cachedAt = 0;
let inFlight: Promise<AdminProfileNavigationItem[]> | undefined; let inFlight: Promise<AdminProfileNavigationItem[]> | undefined;
export const loadAdminProfileNavigation = async (): Promise<AdminProfileNavigationItem[]> => { export const loadAdminProfileNavigation = async (): Promise<AdminProfileNavigationItem[]> => {
if (cachedProfiles && Date.now() - cachedAt < 5_000) return cachedProfiles; if (cachedProfiles && performance.now() - cachedAt < 5_000) return cachedProfiles;
if (inFlight) return inFlight; if (inFlight) return inFlight;
inFlight = directTrpc.admin.profiles.listNavigation inFlight = directTrpc.admin.profiles.listNavigation
.query() .query()
.then((profiles) => { .then((profiles) => {
cachedProfiles = profiles as AdminProfileNavigationItem[]; cachedProfiles = profiles as AdminProfileNavigationItem[];
cachedAt = Date.now(); cachedAt = performance.now();
return cachedProfiles; return cachedProfiles;
}) })
.finally(() => { .finally(() => {
+1 -1
View File
@@ -85,7 +85,7 @@ const main = async (): Promise<void> => {
process.once('SIGINT', () => void stop()); process.once('SIGINT', () => void stop());
process.once('SIGTERM', () => void stop()); process.once('SIGTERM', () => void stop());
while (!stopping) { while (!stopping) {
const now = Date.now(); const now = performance.now();
if (now >= nextWorkspaceCleanupAt) { if (now >= nextWorkspaceCleanupAt) {
nextWorkspaceCleanupAt = now + RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS; nextWorkspaceCleanupAt = now + RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS;
try { try {
@@ -510,7 +510,7 @@ export class GatewayReleaseController {
private async waitForReadiness(operationId: string): Promise<void> { private async waitForReadiness(operationId: string): Promise<void> {
await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.'); await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
const deadline = Date.now() + this.config.readinessTimeoutMs; const deadline = performance.now() + this.config.readinessTimeoutMs;
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`; const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
const frontendUrl = const frontendUrl =
this.config.frontendServeMode === 'static' this.config.frontendServeMode === 'static'
@@ -522,7 +522,7 @@ export class GatewayReleaseController {
const expectedNames = buildGatewayProcessDefinitions(this.config.workspaceRoot, this.config).map( const expectedNames = buildGatewayProcessDefinitions(this.config.workspaceRoot, this.config).map(
(definition) => definition.name (definition) => definition.name
); );
while (Date.now() < deadline) { while (performance.now() < deadline) {
try { try {
const [api, frontend] = await Promise.all([ const [api, frontend] = await Promise.all([
this.fetchImpl(apiUrl), this.fetchImpl(apiUrl),
+2 -2
View File
@@ -92,8 +92,8 @@ export const upgradeReleaseController = async (options: {
} }
try { try {
await options.processManager.start(buildReleaseControllerDefinition(workspace.root, options.config)); await options.processManager.start(buildReleaseControllerDefinition(workspace.root, options.config));
const deadline = Date.now() + (options.readinessTimeoutMs ?? options.config.readinessTimeoutMs); const deadline = performance.now() + (options.readinessTimeoutMs ?? options.config.readinessTimeoutMs);
while (Date.now() < deadline) { while (performance.now() < deadline) {
const matching = (await options.processManager.list()).filter( const matching = (await options.processManager.list()).filter(
(process) => process.name === CONTROLLER_PROCESS_NAME (process) => process.name === CONTROLLER_PROCESS_NAME
); );
+103 -12
View File
@@ -16,13 +16,23 @@
"world_state.deadline_generation", "world_state.deadline_generation",
"general.turn_tick", "general.turn_tick",
"general.recent_war_tick", "general.recent_war_tick",
"general.meta.next_change_tick",
"select_pool.reserved_until_tick", "select_pool.reserved_until_tick",
"select_npc_token.valid_until_tick", "select_npc_token.valid_until_tick",
"select_npc_token.pick_more_from_tick", "select_npc_token.pick_more_from_tick",
"message.time_tick", "message.time_tick",
"message.valid_until_tick", "message.valid_until_tick",
"message.occurred_game_tick",
"message_action.created_game_tick",
"message_action.expires_game_tick",
"message_action.resolved_game_tick",
"message_action.clock_revision",
"message_action.deadline_generation",
"inheritance_ledger.applied_clock_revision",
"inheritance_ledger.applied_deadline_generation",
"auction.open_tick", "auction.open_tick",
"auction.close_tick", "auction.close_tick",
"auction_bid.occurred_game_tick",
"vote_poll.start_tick", "vote_poll.start_tick",
"vote_poll.end_tick", "vote_poll.end_tick",
"clock_suspension.source_revision", "clock_suspension.source_revision",
@@ -34,6 +44,43 @@
"clock_suspension.aligned_tick", "clock_suspension.aligned_tick",
"clock_projection_outbox.target_revision" "clock_projection_outbox.target_revision"
], ],
"wallTimeFields": [
"input_event.created_at",
"input_event.processing_at",
"input_event.completed_at",
"input_event.lease_until",
"read_model_outbox.available_at",
"read_model_outbox.locked_at",
"read_model_outbox.delivered_at",
"web_push_outbox.available_at",
"web_push_outbox.locked_at",
"web_push_outbox.delivered_at",
"turn_daemon_lease.lease_until",
"turn_daemon_lease.heartbeat_at",
"message.created_at_wall",
"message.delete_until_wall",
"message.tombstoned_at_wall",
"message_read_state.updated_at",
"diplomacy_letter.date",
"auction_bid.requested_at_wall",
"auction_bid.created_at",
"auction.finalizing_at",
"auction.finished_at",
"inheritance_ledger.requested_at_wall",
"inheritance_ledger.consumed_at_wall",
"inheritance_ledger.created_at_wall",
"clock_suspension.cut_wall_at",
"clock_suspension.resume_wall_at"
],
"excludedFromReconciliation": [
"all WALL_TIME created_at and updated_at audit fields",
"normal message envelope and five-minute deletion lifecycle",
"account and inheritance receipt timestamps",
"traffic and general-access periods",
"notification and outbox delivery/retry timestamps",
"daemon, worker, editor, gateway, and release leases",
"board, authentication, account, audit, and operator timestamps"
],
"participants": [ "participants": [
{ {
"key": "world-clock", "key": "world-clock",
@@ -56,6 +103,13 @@
"projectionFields": ["general.turn_time"], "projectionFields": ["general.turn_time"],
"owner": "game-engine/turn-daemon" "owner": "game-engine/turn-daemon"
}, },
{
"key": "selection-reselection-deadline",
"policy": "SHIFT",
"authorityFields": ["general.meta.next_change_tick"],
"projectionFields": ["general.meta.next_change", "general.meta.nextChangeAt"],
"owner": "game-engine/select-pool"
},
{ {
"key": "general-recent-war-occurrence", "key": "general-recent-war-occurrence",
"policy": "KEEP", "policy": "KEEP",
@@ -67,8 +121,15 @@
"key": "auction-open-occurrence", "key": "auction-open-occurrence",
"policy": "KEEP", "policy": "KEEP",
"authorityFields": ["auction.open_tick"], "authorityFields": ["auction.open_tick"],
"projectionFields": ["auction.created_at"], "projectionFields": [],
"owner": "game-api/auction" "owner": "game-engine/auction"
},
{
"key": "auction-bid-occurrence",
"policy": "KEEP",
"authorityFields": ["auction_bid.occurred_game_tick"],
"projectionFields": ["auction_bid.event_at"],
"owner": "game-engine/auction"
}, },
{ {
"key": "auction-deadline", "key": "auction-deadline",
@@ -85,18 +146,37 @@
"owner": "game-api/auction-worker" "owner": "game-api/auction-worker"
}, },
{ {
"key": "message-occurrence", "key": "message-action-occurrence",
"policy": "KEEP", "policy": "KEEP",
"authorityFields": ["message.time_tick"], "authorityFields": ["message_action.created_game_tick"],
"projectionFields": ["message.time"], "projectionFields": ["message.time", "message.time_tick"],
"owner": "game-engine/message" "rowScope": "messages with a message_action row only",
"owner": "game-engine/message-action"
}, },
{ {
"key": "message-expiry", "key": "message-action-expiry",
"policy": "SHIFT", "policy": "SHIFT",
"authorityFields": ["message.valid_until_tick"], "authorityFields": ["message_action.expires_game_tick"],
"projectionFields": ["message.valid_until"], "projectionFields": ["message.valid_until", "message.valid_until_tick"],
"owner": "game-engine/message" "rowScope": "messages with a message_action row only",
"owner": "game-engine/message-action"
},
{
"key": "message-action-clock-coordinate",
"policy": "REBUILD",
"authorityFields": ["message_action.clock_revision", "message_action.deadline_generation"],
"projectionFields": [],
"owner": "game-engine/message-action"
},
{
"key": "inheritance-effect-coordinate",
"policy": "KEEP",
"authorityFields": [
"inheritance_ledger.applied_clock_revision",
"inheritance_ledger.applied_deadline_generation"
],
"projectionFields": [],
"owner": "game-engine/inheritance"
}, },
{ {
"key": "vote-start-occurrence", "key": "vote-start-occurrence",
@@ -127,7 +207,7 @@
"owner": "game-engine/npc-selection" "owner": "game-engine/npc-selection"
}, },
{ {
"key": "accepted-command-coordinate", "key": "daemon-command-coordinate",
"policy": "KEEP", "policy": "KEEP",
"authorityFields": [ "authorityFields": [
"input_event.accepted_game_tick", "input_event.accepted_game_tick",
@@ -139,7 +219,7 @@
"input_event.processing_clock_revision", "input_event.processing_clock_revision",
"input_event.processing_deadline_generation" "input_event.processing_deadline_generation"
], ],
"owner": "game-api/input-event" "owner": "game-engine/input-event-claim"
}, },
{ {
"key": "tournament-deadlines", "key": "tournament-deadlines",
@@ -220,6 +300,17 @@
"clock_projection_outbox.available_at", "clock_projection_outbox.available_at",
"clock_projection_outbox.locked_at", "clock_projection_outbox.locked_at",
"clock_projection_outbox.applied_at", "clock_projection_outbox.applied_at",
"message.created_at_wall",
"message.delete_until_wall",
"message.tombstoned_at_wall",
"message_action.created_at_wall",
"message_action.updated_at_wall",
"auction.created_at",
"auction.updated_at",
"auction_bid.requested_at_wall",
"auction_bid.created_at",
"inheritance_ledger.requested_at_wall",
"inheritance_ledger.consumed_at_wall",
"*.created_at", "*.created_at",
"*.updated_at" "*.updated_at"
] ]
+33 -10
View File
@@ -3,11 +3,13 @@
## Product contract ## Product contract
Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000` Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000`
ticks. Wall time is an observation and operational-control input, never the ticks. Wall time is separately authoritative for account, community, audit,
authority for gameplay ordering. A long suspension advances the observed game lease, retry, notification, and operational rules. It is never projected into a
coordinate to the resume wall instant without replaying skipped turns, monthly game deadline. A long suspension advances the observed game coordinate to the
events, RNG, auctions, or tournaments. Every movable future schedule is shifted resume wall instant without replaying skipped turns, monthly events, RNG,
by the same exact tick delta, including the sub-turn remainder. auctions, or tournaments. Every movable future GAME schedule is shifted by the
same exact tick delta, including the sub-turn remainder. WALL occurrences and
deadlines are outside that operation.
The clock state is stored in `world_state`: The clock state is stored in `world_state`:
@@ -50,6 +52,16 @@ The authoritative registry is
architecture gate rejects a new tick/revision field that is absent from that architecture gate rejects a new tick/revision field that is absent from that
inventory. inventory.
The participant set contains only GAME authority or its projections: world and
turn cursors, general turns/recent-war occurrences/reselection deadlines,
auction occurrences/deadlines, actionable-message occurrences/deadlines,
vote deadlines, selection/NPC windows, input-event game coordinates,
tournament Redis deadlines, and clock-operation metadata. A normal message's
`created_at_wall`/`delete_until_wall`, inheritance receipts, notification and
outbox retry timestamps, leases, and audit columns are explicitly excluded.
The former broad `message-expiry` meaning is split into
`message-action-expiry`; an envelope has no GAME lifetime.
## Unification wait ## Unification wait
A unification month with an invader choice changes `RUNNING -> SUSPENDED` and A unification month with an invader choice changes `RUNNING -> SUSPENDED` and
@@ -109,9 +121,11 @@ turn-daemon fencing row
-> Redis outbox projection -> Redis outbox projection
``` ```
The ordinary turn flush already validates phase, revision, and deadline The ordinary turn flush and daemon command claim validate phase, revision, and
generation after taking this lock prefix. Clock operation participants will be deadline generation after taking this lock prefix. WALL-only message/account
added without changing that prefix. operations do not take this lock and remain available while suspended. Hybrid
operations commit their GAME effect only behind this fence; inheritance debit,
receipt, effect, and command success are one transaction.
## Opening invariant ## Opening invariant
@@ -137,8 +151,17 @@ all present. Before that boundary, the loader and ordinary turn-flush fence both
treat the row as legacy `MANUAL`; the first fenced flush installs the complete treat the row as legacy `MANUAL`; the first fenced flush installs the complete
snapshot atomically instead of trusting the new column's `RUNNING` database snapshot atomically instead of trusting the new column's `RUNNING` database
default. Input-event acceptance does not use that compatibility fallback: an default. Input-event acceptance does not use that compatibility fallback: an
API or worker may enqueue gameplay only after the authoritative clock is fully API or worker records only a DB-wall receipt, then the daemon establishes the
initialized. GAME coordinate while claiming under the authoritative fence. Rolling-upgrade
payload coordinates may be parsed and ignored, but never become rule authority.
Migration `20260903140000_split_message_wall_and_game_time` separates message
envelopes from actions and adds explicit auction-bid occurrence/request facts,
inheritance receipts, and selection cooldown tick authority. Legacy projection
columns remain temporarily for old readers. A missing GAME tick fails closed;
it never changes the rule to WALL_TIME. A WALL rule likewise never derives an
authority tick. See [`time-domains.md`](./time-domains.md) for the complete
inventory and migration policy.
No active participant remains `FORBID`. Tournament writes carry No active participant remains `FORBID`. Tournament writes carry
tick/revision/generation coordinates and are revision-fenced in Redis. tick/revision/generation coordinates and are revision-fenced in Redis.
+36 -18
View File
@@ -1,17 +1,20 @@
# 게임 시계 # 게임 시계
게임 진행 시각은 `world_state.clock_tick`이 기준입니다. 벽시계는 daemon lease, 시간 규칙은 `GAME_TIME`, `WALL_TIME`, `MONOTONIC_ELAPSED_TIME`
요청 timeout, 처리 budget과 같은 운영 제어에만 사용합니다. 장수 턴, 메시지 나뉩니다. 게임 진행의 권위는 `world_state.clock_tick`, 영속 wall
유효기간, 투표, 경매와 대회 마감은 game tick 또는 그 tick에서 투영한 시각을 판정의 권위는 PostgreSQL UTC 시계, 프로세스 내부 경과시간의 권위는
사용합니다. monotonic clock입니다. 장수 턴·외교 효력·게임 경매·투표·대회는 GAME,
일반 메시지·계정·감사·lease·retry는 WALL입니다. 전체 필드별 계약은
[`time-domains.md`](./time-domains.md)를 따릅니다.
한 턴은 항상 `36,000,000` tick입니다. `tick_seconds`가 바뀌면 현재 표시 한 턴은 항상 `36,000,000` tick입니다. `tick_seconds`가 바뀌면 현재 GAME 표시
시각이 유지되도록 `clock_base_time`을 다시 계산하므로, 기존 장수 턴 순서와 시각이 유지되도록 `clock_base_time`을 다시 계산하므로, 기존 장수 턴 순서와
남은 턴 수가 보존됩니다. DateTime 필드는 이전 데이터와 화면을 위한 투영값이며 남은 턴 수가 보존됩니다. GAME 규칙의 DateTime은 화면/레거시 투영일 뿐이며
tick 필드가 존재하면 tick이 우선합니다. tick이 반드시 authority입니다. WALL 규칙은 tick이 없어도 정상이며
DateTime을 tick으로 변환해 판정하지 않습니다.
운영 중 턴 간격 변경은 Gateway의 내구성 런타임 작업으로만 수행합니다. 같은 운영 중 턴 간격 변경은 Gateway의 내구성 런타임 작업으로만 수행합니다. 같은
transaction에서 `world_state`, 장수·경매·메시지·설문 투영값과 checkpoint를 transaction에서 `world_state`, 장수·경매·actionable message·설문 투영값과 checkpoint를
갱신하며 기존 역사/행동 로그의 `created_at`은 다시 쓰지 않습니다. 토너먼트의 갱신하며 기존 역사/행동 로그의 `created_at`은 다시 쓰지 않습니다. 토너먼트의
Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다. Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
@@ -29,6 +32,18 @@ Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
프로필 설치 시 선택한 모드는 DB에 저장됩니다. daemon의 환경변수는 로드한 프로필 설치 시 선택한 모드는 DB에 저장됩니다. daemon의 환경변수는 로드한
모드를 명시적으로 덮어쓸 때만 사용해 주세요. 모드를 명시적으로 덮어쓸 때만 사용해 주세요.
`SUSPENDED``RECONCILING`에서는 `GameClock.nowTick()`이 wall anchor 이후의
현실 경과시간을 더하지 않고 저장된 `clock_tick`을 그대로 반환합니다. 따라서
24시간 동안 정지해도 actionable message, 토너먼트와 국가 베팅의 GAME deadline은
줄지 않습니다. 일반 메시지 envelope와 5분 삭제 기한은 별도의 DB WALL_TIME이라
같은 기간 계속 흐릅니다. 정지 전에 도착한 등용장도 envelope로 계속 수신·열람할
수 있지만, 등용 수락 효과는 daemon GAME fence가 다시 열릴 때까지 적용되지 않습니다.
이미 열린 토너먼트·국가 베팅에는 `SUSPENDED` 중에도 새 베팅을 제출할 수 있습니다.
이때 베팅 가능 여부는 frozen GAME coordinate로 판정하고, 재화 mutation은 현재
phase/revision/generation을 다시 잠가 검증합니다. 단계 전환·마감·정산은 실행하지
않으며, 원자적 reconciliation이 진행되는 `RECONCILING`에서는 새 베팅도 받지 않습니다.
## 중단 후 재개 ## 중단 후 재개
realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용합니다. realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용합니다.
@@ -45,21 +60,24 @@ realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용
장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행 장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행
시각으로 다시 고정합니다. 시각으로 다시 고정합니다.
DB migration은 기존 DateTime 에서 tick을 채웁니다. 새 설치와 migration DB migration은 GAME 규칙의 기존 DateTime 투영에서 tick을 채웁니다. 새 설치와 migration
재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은 재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은
무기한 호환값은 안전한 정수 범위를 넘을 수 있으므로 tick을 `NULL`로 두고 무기한 호환값은 일반 메시지의 투영일 뿐입니다. actionable deadline은
DateTime fallback을 사용합니다. `expires_game_tick`, 일반 삭제 deadline은 `delete_until_wall`만이 authority이며
NULL에 따라 다른 시계로 fallback하지 않습니다.
## 비동기 작업의 시계 경계 ## 비동기 작업의 시계 경계
게임 규칙의 수락·입찰·예약 시각은 logical game time을 사용하지만 daemon 외부 요청은 `InputEvent.createdAt` DB WALL_TIME으로 접수합니다. API payload가
queue의 `InputEvent.createdAt`, worker history retention과 timeout은 운영 game tick을 미리 고정하지 않으며, daemon이 clock lock/fence 아래서 claim할 때
벽시계를 사용합니다. NPC 빙의 enqueue는 현재 logical game time을 event `accepted_game_tick`/세대와 `processing_game_tick`/세대를 확정합니다. NPC,
payload의 `acceptedGameAt`에 고정합니다. queue에 들어갈 때 유효했던 token은 선택, 투표, 경매, 유산 효과는 처리 tick으로 검증하며 stale revision은
처리 전 game tick이 진행해도 이 저장된 논리 수락 시각으로 다시 검증합니다. 적용하지 않습니다. worker history retention·lease·retry는 DB WALL_TIME,
프로세스 대기 budget은 monotonic time입니다.
경매 입찰은 같은 logical tick에서 여러 번 일어날 수 있습니다. bid 표시 경매 입찰은 `requested_at_wall`로 현실 요청을, `occurred_game_tick`으로 GAME
시각은 같은 game time을 보존하고, optimistic 경합 판정은 임의 UUID의 사건을 별도 기록합니다. bid 표시 투영은 같은 game time을 보존하고,
optimistic 경합 판정은 임의 UUID의
사전순이 아니라 읽은 `latest_event_id`를 버전 토큰으로 사용합니다. worker 사전순이 아니라 읽은 `latest_event_id`를 버전 토큰으로 사용합니다. worker
재시작 시 `OPEN``close_tick` deadline에, 이미 마감 판정이 끝난 재시작 시 `OPEN``close_tick` deadline에, 이미 마감 판정이 끝난
`FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시 `FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시
+166
View File
@@ -0,0 +1,166 @@
# Time-domain inventory
This document is the authoritative classification of persistent timestamps,
deadlines, cooldowns, and process-local elapsed-time rules. Classification is
per rule, not per table. A feature may record both a wall occurrence and a game
effect; those are two facts, never one fallback clock.
## Domain contract
| Domain | Authority | Advances while suspended/reconciling | Reconciliation |
| ------------------------ | -------------------------------------------------------------- | ------------------------------------ | ----------------------------- |
| `GAME_TIME` | `world_state.clock_tick` under phase/revision/generation fence | no | `SHIFT`, `KEEP`, or `REBUILD` |
| `WALL_TIME` | PostgreSQL UTC `CURRENT_TIMESTAMP` for persistent decisions | yes | never |
| `MONOTONIC_ELAPSED_TIME` | `performance.now()` / monotonic process clock | process-local only | never persisted |
`GameTick`, `ClockRevision`, `DeadlineGeneration`, `WallInstant`, and
`MonotonicDuration` name these meanings in new/refactored APIs. Existing
`createdAt`/`updatedAt` fields remain wall audit timestamps unless this inventory
explicitly calls them game projections.
## Game database inventory
`Pause` means whether the rule continues to age during `SUSPENDED` or
`RECONCILING`. `Projection` means a non-authoritative compatibility/display
representation.
| Table / rule / field(s) | Current meaning | Domain and authority | Pause | Reconcile / projection | Decision and reason |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `world_state.clock_tick` | observed world coordinate | GAME, self-authoritative | stop | REBUILD | root of all game-time decisions |
| `world_state.last_turn_tick` | executed turn cursor | GAME | stop | SHIFT | execution order must preserve skipped-turn policy |
| `world_state.clock_revision`, `deadline_generation` | clock/future-deadline generations | GAME metadata | stop | REBUILD | stale commands/workers must fail their fence |
| `world_state.clock_base_time`, `clock_wall_anchor` | tick-to-date mapping and wall observation anchor | GAME projection metadata | n/a | REBUILD | not business wall deadlines |
| `world_state.updated_at` | row audit | WALL, DB UTC | advance | excluded | operational history is not shifted |
| `clock_suspension.source_revision`, `target_revision`, `cut_tick`, `catch_up_ticks`, `gap_ticks`, `shift_ticks`, `aligned_tick` | reconciliation plan/audit | GAME metadata | stop | KEEP | immutable clock operation facts |
| `clock_suspension.cut_wall_at`, `resume_wall_at`, `created_at`, `updated_at` | operator/runtime occurrence audit | WALL, DB UTC | advance | excluded | records when the real operation occurred |
| `clock_projection_outbox.target_revision` | target GAME generation | GAME metadata | stop | KEEP | projection fence |
| `clock_projection_outbox.available_at`, `locked_at`, `applied_at`, `created_at`, `updated_at` | retry/lease/audit | WALL, DB UTC | advance | excluded | worker control cannot pause with game time |
| `clock_reconciliation_participant` checksum/count/policy | immutable operation evidence | GAME operation metadata | n/a | KEEP | evidence, not a deadline |
| `input_event.accepted_game_tick`, `accepted_clock_revision`, `accepted_deadline_generation` | daemon-claim boundary | GAME metadata | stop | KEEP across matching revision; rebase pending legacy rows only | API does not pre-stamp these; daemon owns acceptance |
| `input_event.processing_game_tick`, `processing_clock_revision`, `processing_deadline_generation` | actual mutation boundary | GAME metadata | stop | KEEP | effect validation/RNG uses this coordinate |
| `input_event.created_at`, `processing_at`, `completed_at`, `lease_until` | request receipt, processing audit, lease | WALL, DB UTC | advance | excluded | external occurrence and worker lease |
| `read_model_outbox.*_at`, `web_push_outbox.*_at` | availability, claim, delivery, audit | WALL, DB UTC | advance | excluded | retry and notification delivery are operational |
| `turn_daemon_lease.lease_until`, `heartbeat_at` | daemon liveness | WALL, DB UTC | advance | excluded | a paused game must still lose a dead daemon lease |
| `general.turn_tick` | next general turn | GAME | stop | SHIFT; `turn_time` projection | determines engine order |
| `general.recent_war_tick` | past battle occurrence | GAME | stop | KEEP; `recent_war_time` projection | historical event does not move |
| `general.meta.next_change_tick` | N-turn reselection cooldown | GAME | stop | SHIFT; `next_change`/`nextChangeAt` projections | expressed in turns; missing tick fails closed |
| `general.created_at`, `updated_at` | entity audit | WALL, DB UTC | advance | excluded | no gameplay deadline meaning |
| `select_pool.reserved_until_tick` | selection reservation deadline | GAME | stop | SHIFT; `reserved_until` projection | reservation is measured in game turns |
| `select_npc_token.valid_until_tick`, `pick_more_from_tick` | NPC selection windows | GAME | stop | SHIFT; DateTime projections | token is a game selection schedule; missing ticks fail closed |
| `general_access_log.last_refresh`, `last_action_at`; `general_access_batch.created_at`; `traffic_period.started_at`, `last_refresh`; `traffic_period_general.last_refresh` | traffic/access accounting | WALL, DB UTC | advance | excluded | community/operations usage, not world progression |
| `message.created_at_wall`, `delete_until_wall`, `tombstoned_at_wall` | envelope send/delete lifecycle | WALL, DB UTC | advance | excluded | normal messages work while the game is paused; deletion is real five minutes |
| `message.occurred_game_tick` | optional game context | GAME occurrence | stop | KEEP | context only, never deletion authority |
| `message.time`, `time_tick`, `valid_until`, `valid_until_tick` | rolling compatibility projections | projection only | n/a | recompute only for `message_action`; general-envelope values never decide lifecycle | old columns are not fallback authority |
| `message_action.created_game_tick`, `resolved_game_tick` | action occurrence/resolution | GAME occurrence | stop | KEEP | actionable message lifecycle is separate from envelope |
| `message_action.expires_game_tick` | proposal response deadline | GAME | stop | SHIFT | remaining game duration survives pause |
| `message_action.clock_revision`, `deadline_generation` | response fence | GAME metadata | stop | REBUILD | stale responses are rejected |
| `message_action.created_at_wall`, `updated_at_wall`; `message_read_state.updated_at` | audit/read occurrence | WALL, DB UTC | advance | excluded | community UX state |
| `diplomacy_letter.date` | document authored/sent time | WALL, DB UTC | advance | excluded | game effect dates live in diplomacy/action state, not the document timestamp |
| diplomacy war/nonaggression start/end month data | diplomatic effect schedule | GAME calendar | stop | handled by engine schedule | affects world turns and war validity |
| `inheritance_point.updated_at`, `inheritance_log.created_at`, `inheritance_result.created_at`, baseline/user-state audit fields | account ledger/result audit | WALL, DB UTC | advance | excluded | account/external-currency history |
| `inheritance_ledger.requested_at_wall`, `consumed_at_wall`, `created_at_wall` | direct purchase receipt | WALL, DB UTC | advance | excluded | real request/debit receipt |
| `inheritance_ledger.applied_clock_revision`, `applied_deadline_generation` | game-effect fence metadata | GAME metadata | stop | KEEP | no `applied_game_tick`: current direct effects are timeless immediate state changes |
| inheritance command `input_event` | durable effect state/idempotency | WALL receipt + GAME processing fence | mixed, separated | only GAME coordinate participates | one transaction commits debit, receipt, effect, and command success; failure leaves durable input event and no debit |
| `auction.open_tick`, `auction_bid.occurred_game_tick` | open/bid game occurrence | GAME | stop | KEEP; bid `event_at` is projection | event order/RNG/replay context |
| `auction.close_tick` | in-world close deadline | GAME | stop | SHIFT; `close_at` projection | authoritative worker/finalizer deadline; missing tick fails closed |
| `auction_bid.requested_at_wall`, `created_at`; `auction.finalizing_at`, `finished_at`, `created_at`, `updated_at` | request/processing/audit | WALL, DB UTC | advance | excluded | real action and recovery history |
| `auction.latest_event_at` | optimistic compatibility projection of latest game event | GAME projection | stop | follows authoritative event tick | never used as wall deadline |
| `vote_poll.start_tick`, `end_tick` | poll occurrence/deadline | GAME | stop | KEEP/SHIFT; `start_at`/`end_at` projections | poll is an in-world survey; missing deadline tick fails closed |
| `vote_poll.closed_at`, `created_at`, `updated_at`; `vote.created_at`; `vote_comment.created_at` | closure/user/audit occurrence | WALL, DB UTC | advance | excluded | closure receipt and community content history |
| tournament `nextTick`, `bettingCloseTick` in Redis | stage/betting deadlines | GAME | stop | REBUILD; `nextAt`/`bettingCloseAt` projections | stages advance with the world; legacy date-only state fails closed |
| nation betting open/close year-month and tournament phase | in-world availability | GAME calendar/tick | stop | engine/Redis participant | tied to tournament turns |
| tournament/nation bet submission | user WALL request + effect at the current frozen GAME coordinate | WALL + GAME, separated | submission is allowed during `SUSPENDED`; GAME deadline does not age | receipt excluded; GAME availability/fence retained | pausing stage progress must not close an already-open betting window |
| `nation_betting.*_at`, `nation_bet.*_at` | user/audit occurrence | WALL, DB UTC | advance | excluded | receipts, not close authority |
| `game_history.date`, old-general `turntime`, archived projected dates | archived game-calendar projection | GAME historical display | stop | KEEP, never shifted | immutable archive/replay record |
| archive/entity `created_at`, cancellation `opened_at`/`cancelled_at`, unification `completed_at` | operation/archive audit | WALL, DB UTC | advance | excluded | real creation/completion facts |
| `general_turn_revision.lease_expires_at`, `nation_turn_revision.lease_expires_at` and audit timestamps | edit lease/revision audit | WALL, DB UTC | advance | excluded | editor concurrency timeout |
| board post/comment, log/error/event, legacy migration timestamps | content/audit/migration history | WALL, DB UTC | advance | excluded | community and operational evidence |
The year-9999 message sentinel remains a legacy projection only.
`MAX_SAFE_GAME_TICK` is the separate GAME-domain infinite sentinel. Neither is
converted into or used as the other domain's ordinary deadline.
## Gateway database inventory
Gateway has no gameplay clock authority. Every Gateway `DateTime` is WALL_TIME:
- `app_user`: identity/session/icon/terms/privacy/Kakao/grace/deletion/login and
`created_at`/`updated_at` fields.
- access grants, retired identities, admin audits, user icons, legacy member
logs, and migration timestamps.
- profile lifecycle `preopen_at`, `open_at`, `scheduled_start_at`, build request,
start/completion/last-used, and row audit timestamps. These are real control
plane schedules; they do not replace a profile's `world_state.clock_tick`.
- subscriptions/preferences/receipts/notifications and web-push delivery
`available_at`, `locked_at`, `delivered_at`, expiration and audit fields.
- runtime actions, operations, releases, and bulk releases: schedule, start,
completion, retry, lease, heartbeat, successful and audit timestamps.
Competitive Gateway operation/release claim and lease renewal decisions read
PostgreSQL `CURRENT_TIMESTAMP` inside the persistence transaction; the caller's
poll timestamp is not authoritative. A Gateway PREOPEN wall schedule is an operational request; once
the game exists, gameplay schedules use the game database tick.
## Process-local monotonic inventory
The following are `MONOTONIC_ELAPSED_TIME` and are never persisted: daemon/RPC
wait budgets, turn processing budgets, worker poll/resync intervals, lock
acquisition waits, readiness loops, short-lived cache TTLs, latency metrics, and
test wait loops. Production implementations use `performance.now()` where an
elapsed duration is measured. `Date.now()`/`new Date()` remains valid only when
creating or formatting a WALL occurrence, calculating a non-competitive auth
TTL for an external protocol, or providing an explicit test clock.
## API phase policy
| Operation | During `SUSPENDED` / `RECONCILING` | Fence |
| -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| normal public/private/nation message send/read/delete, including receiving/reading an existing recruitment letter envelope | allowed | WALL DB transaction only; actionable deadline remains frozen GAME state |
| notification/account/inheritance history/audit reads | allowed | WALL |
| actionable message response | rejected/queued except the explicitly authorized unification response | daemon clock phase/revision/generation |
| tournament/nation bet submission while its GAME window is open | allowed in `SUSPENDED`; rejected in `RECONCILING` | frozen GAME deadline + phase/revision/generation fence |
| tournament stage transition/close/settlement and nation-bet close/settlement | not applied | daemon GAME fence |
| turn, reservation, war/diplomacy effect, auction, vote, other tournament mutation | not applied | daemon GAME fence |
| direct inheritance state mutation | rejected if the daemon GAME fence cannot commit | atomic input-event + revision/generation fence |
There is currently no account-only inheritance purchase endpoint. Direct
inheritance commands use policy 1 (no debit if game mutation cannot commit), not
an ambiguous partially-applied state. Their `InputEvent.requestId` is the durable
idempotency/effect state; `InheritanceLedger.requestId` proves the one successful
receipt.
## `loadCurrentGameTime` call-site audit
All production call sites were reviewed. The remaining uses are GAME-only:
- `messages/store`: create/read/invalidate the separate `message_action`; normal
envelope creation, display, read state, and deletion do not load game time.
- `messages/diplomaticResponse`: game-effect/log calendar context after an
actionable response fence; the letter's authored date is DB WALL_TIME.
- auction `open`, `scheduler`, `worker`, and router: open/close tick projection,
due evaluation, and fence context; bid receipt time is separate WALL_TIME.
- vote router, tournament router/worker: GAME poll/stage deadlines and Redis
projection fences.
- troop/general/join selection routers: current world turn/schedule context;
the NPC reservation mutation holds the clock advisory lock and `world_state`
row fence before reading it.
- lobby: display-only projected server game time and phase.
No inheritance receipt, ordinary message timestamp/delete rule, audit log,
lease, outbox retry, API timeout, or worker budget calls this helper.
Ordinary message send/read-state/delete mutations retain their durable API
`InputEvent` transaction and read-model journal, but use the WALL-only input
boundary. That boundary deliberately does not acquire the game clock advisory
lock, so a reconciliation transaction cannot unnecessarily serialize community
messaging. Actionable responses continue to use the GAME-fenced boundary.
## Migration boundary
Migration `20260903140000_split_message_wall_and_game_time` adds and backfills
the explicit message, action, auction-bid, inheritance receipt, and selection
cooldown authorities. It never extends an already-expired message delete window.
Old projection columns remain during rolling deployment, but new code never
chooses a clock by NULL fallback: GAME rules require their tick; WALL rules use
their wall column. The disposable migration verifier covers populated upgrade,
indexes/constraints, replay safety, and a second no-op deploy.
+27 -3
View File
@@ -9,13 +9,18 @@ declare const gameTickBrand: unique symbol;
declare const observedGameInstantBrand: unique symbol; declare const observedGameInstantBrand: unique symbol;
declare const scheduleInstantBrand: unique symbol; declare const scheduleInstantBrand: unique symbol;
declare const clockRevisionBrand: unique symbol; declare const clockRevisionBrand: unique symbol;
declare const deadlineGenerationBrand: unique symbol;
declare const wallInstantBrand: unique symbol; declare const wallInstantBrand: unique symbol;
declare const monotonicDurationBrand: unique symbol;
export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' }; export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' };
export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' }; export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' };
export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' }; export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' };
export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' }; export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' };
export type DeadlineGeneration = number & { readonly [deadlineGenerationBrand]: 'DeadlineGeneration' };
export type WallInstant = Date & { readonly [wallInstantBrand]: 'WallInstant' }; export type WallInstant = Date & { readonly [wallInstantBrand]: 'WallInstant' };
/** Process-local elapsed milliseconds; never persist this value as a business timestamp. */
export type MonotonicDuration = number & { readonly [monotonicDurationBrand]: 'MonotonicDuration' };
export interface ClockAlignmentPlan { export interface ClockAlignmentPlan {
policy: ClockAlignmentPolicy; policy: ClockAlignmentPolicy;
@@ -59,6 +64,13 @@ export const asClockRevision = (revision: number): ClockRevision => {
return revision as ClockRevision; return revision as ClockRevision;
}; };
export const asDeadlineGeneration = (generation: number): DeadlineGeneration => {
if (!Number.isSafeInteger(generation) || generation < 1) {
throw new Error(`Deadline generation must be a positive safe integer: ${generation}`);
}
return generation as DeadlineGeneration;
};
export const asWallInstant = (instant: Date): WallInstant => { export const asWallInstant = (instant: Date): WallInstant => {
if (Number.isNaN(instant.getTime())) { if (Number.isNaN(instant.getTime())) {
throw new Error('Wall instant must be a valid date.'); throw new Error('Wall instant must be a valid date.');
@@ -66,6 +78,13 @@ export const asWallInstant = (instant: Date): WallInstant => {
return new Date(instant.getTime()) as WallInstant; return new Date(instant.getTime()) as WallInstant;
}; };
export const asMonotonicDuration = (milliseconds: number): MonotonicDuration => {
if (!Number.isFinite(milliseconds) || milliseconds < 0) {
throw new Error(`Monotonic duration must be a non-negative finite number: ${milliseconds}`);
}
return milliseconds as MonotonicDuration;
};
export const inferClockPhase = (mode: GameClockMode): GameClockPhase => (mode === 'manual' ? 'MANUAL' : 'RUNNING'); export const inferClockPhase = (mode: GameClockMode): GameClockPhase => (mode === 'manual' ? 'MANUAL' : 'RUNNING');
const GAME_CLOCK_PHASES: readonly GameClockPhase[] = [ const GAME_CLOCK_PHASES: readonly GameClockPhase[] = [
@@ -136,8 +155,7 @@ const buildAlignmentPlan = (input: {
const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000; const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000;
const gapTicks = asGameTick( const gapTicks = asGameTick(
requireSafeTick( requireSafeTick(
wholeSeconds * input.ticksPerSecond + wholeSeconds * input.ticksPerSecond + Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000)
Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000)
) )
); );
const catchUpTicks = asGameTick(input.catchUpTicks ?? 0); const catchUpTicks = asGameTick(input.catchUpTicks ?? 0);
@@ -250,7 +268,13 @@ export class GameClock {
} }
nowTick(wallNow: Date): number { nowTick(wallNow: Date): number {
if (this.mode === 'manual' || this.phase === 'MANUAL' || this.phase === 'COMPLETED') { if (
this.mode === 'manual' ||
this.phase === 'MANUAL' ||
this.phase === 'SUSPENDED' ||
this.phase === 'RECONCILING' ||
this.phase === 'COMPLETED'
) {
return this.tick; return this.tick;
} }
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow); const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
@@ -24,7 +24,7 @@ export interface TournamentClockFence {
phaseKey: string; phaseKey: string;
revision: number; revision: number;
deadlineGeneration: number; deadlineGeneration: number;
phase: 'RUNNING'; phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
} }
const WRITE_TOURNAMENT_PROJECTION_SCRIPT = ` const WRITE_TOURNAMENT_PROJECTION_SCRIPT = `
+3 -10
View File
@@ -190,7 +190,7 @@ export type TurnDaemonCommand =
requestId?: string; requestId?: string;
auctionId: number; auctionId: number;
expectedCloseAt?: string; expectedCloseAt?: string;
expectedCloseTick?: number; expectedCloseTick: number;
} }
| { | {
type: 'auctionOpen'; type: 'auctionOpen';
@@ -260,7 +260,6 @@ export type TurnDaemonCommand =
voteId: number; voteId: number;
generalId: number; generalId: number;
selection: number[]; selection: number[];
acceptedGameTick?: number;
} }
| { | {
type: 'setNationSetting'; type: 'setNationSetting';
@@ -386,15 +385,12 @@ export type TurnDaemonCommand =
ownerLegacyPenalty?: Record<string, unknown>; ownerLegacyPenalty?: Record<string, unknown>;
generalId: number; generalId: number;
tokenNonce: number; tokenNonce: number;
acceptedGameAt?: string;
} }
| { | {
type: 'selectPoolReserve'; type: 'selectPoolReserve';
requestId?: string; requestId?: string;
userId: string; userId: string;
seedOwnerIdentity: string | number; seedOwnerIdentity: string | number;
acceptedGameAt: string;
acceptedGameTick?: number;
} }
| { | {
type: 'selectPoolCreate'; type: 'selectPoolCreate';
@@ -407,8 +403,6 @@ export type TurnDaemonCommand =
ownerPicture?: string; ownerPicture?: string;
ownerImageServer?: number; ownerImageServer?: number;
ownerIconRevision?: string; ownerIconRevision?: string;
acceptedGameAt?: string;
acceptedGameTick?: number;
} }
| { | {
type: 'selectPoolReselect'; type: 'selectPoolReselect';
@@ -416,8 +410,6 @@ export type TurnDaemonCommand =
userId: string; userId: string;
ownerDisplayName: string; ownerDisplayName: string;
uniqueName: string; uniqueName: string;
acceptedGameAt?: string;
acceptedGameTick?: number;
} }
| { | {
type: 'auctionBid'; type: 'auctionBid';
@@ -426,7 +418,6 @@ export type TurnDaemonCommand =
auctionId: number; auctionId: number;
generalId: number; generalId: number;
amount: number; amount: number;
acceptedGameTick?: number;
tryExtendCloseDate?: boolean; tryExtendCloseDate?: boolean;
}; };
@@ -505,6 +496,7 @@ export type TurnDaemonCommandResult =
ok: true; ok: true;
auctionId: number; auctionId: number;
closeAt: string; closeAt: string;
closeTick: number;
} }
| { | {
type: 'auctionOpen'; type: 'auctionOpen';
@@ -835,6 +827,7 @@ export type TurnDaemonCommandResult =
ok: true; ok: true;
auctionId: number; auctionId: number;
closeAt: string; closeAt: string;
closeTick: number;
} }
| { | {
type: 'auctionBid'; type: 'auctionBid';
+18
View File
@@ -13,6 +13,24 @@ import {
} from '../src/time/GameClock.js'; } from '../src/time/GameClock.js';
describe('GameClock', () => { describe('GameClock', () => {
it.each(['SUSPENDED', 'RECONCILING'] as const)(
'keeps the authoritative tick frozen across 24 wall hours while %s',
(phase) => {
const clock = new GameClock({
baseTime: new Date('0200-01-01T00:00:00.000Z'),
tick: 12_345,
mode: 'realtime',
wallAnchor: new Date('2026-09-03T15:00:00.000Z'),
turnSeconds: 600,
phase,
revision: 7,
});
expect(clock.nowTick(new Date('2026-09-04T15:00:00.000Z'))).toBe(12_345);
expect(clock.now(new Date('2026-09-04T15:00:00.000Z'))).toEqual(clock.tickToDate(12_345));
}
);
const baseTime = new Date('2042-01-01T00:00:00.000Z'); const baseTime = new Date('2042-01-01T00:00:00.000Z');
it('projects the fixed Ref turn tick and ignores wall time in manual mode', () => { it('projects the fixed Ref turn tick and ignores wall time in manual mode', () => {
+1
View File
@@ -22,6 +22,7 @@
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh", "verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh", "verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
"verify:migration:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh", "verify:migration:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh",
"verify:migration:time-domains": "sh scripts/verify-time-domain-migration.sh",
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs", "coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma", "prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma" "prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
+55 -1
View File
@@ -438,15 +438,45 @@ model Message {
type String type String
src Int src Int
dest Int dest Int
/// Legacy game-date projection. Never use this as the wall occurrence authority.
time DateTime time DateTime
/// Legacy game-date projection coordinate. New rules use occurredGameTick or MessageAction.
timeTick BigInt? @map("time_tick") timeTick BigInt? @map("time_tick")
/// Legacy envelope/action visibility projection retained for rolling compatibility.
validUntil DateTime @map("valid_until") validUntil DateTime @map("valid_until")
/// Legacy action deadline projection retained for rolling compatibility.
validUntilTick BigInt? @map("valid_until_tick") validUntilTick BigInt? @map("valid_until_tick")
createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3)
deleteUntilWall DateTime @default(dbgenerated("((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '5 minutes')")) @map("delete_until_wall") @db.Timestamp(3)
tombstonedAtWall DateTime? @map("tombstoned_at_wall") @db.Timestamp(3)
occurredGameTick BigInt? @map("occurred_game_tick")
message Json message Json
action MessageAction?
@@index([mailbox, type, id])
@@index([deleteUntilWall])
@@map("message") @@map("message")
} }
model MessageAction {
messageId Int @id @map("message_id")
actionType String @map("action_type") @db.VarChar(64)
status String @default("PENDING") @db.VarChar(16)
createdGameTick BigInt @map("created_game_tick")
expiresGameTick BigInt? @map("expires_game_tick")
resolvedGameTick BigInt? @map("resolved_game_tick")
clockRevision BigInt @map("clock_revision")
deadlineGeneration BigInt @map("deadline_generation")
createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3)
updatedAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at_wall") @db.Timestamp(3)
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@index([status, expiresGameTick])
@@map("message_action")
}
model RankData { model RankData {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
nationId Int @default(0) @map("nation_id") nationId Int @default(0) @map("nation_id")
@@ -857,6 +887,26 @@ model InheritanceLog {
@@map("inheritance_log") @@map("inheritance_log")
} }
/// WALL_TIME purchase/consume receipt for an inheritance command. The linked
/// input_event owns the authoritative GAME clock coordinate and retry state.
model InheritanceLedger {
id BigInt @id @default(autoincrement())
requestId String @unique @map("request_id")
userId String @map("user_id")
action String
cost Float
status String @default("APPLIED")
requestedAtWall DateTime @map("requested_at_wall") @db.Timestamp(3)
consumedAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("consumed_at_wall") @db.Timestamp(3)
appliedClockRevision BigInt @map("applied_clock_revision")
appliedDeadlineGeneration BigInt @map("applied_deadline_generation")
createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3)
@@index([userId, id])
@@index([status, id])
@@map("inheritance_ledger")
}
model InheritanceResult { model InheritanceResult {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
legacyId Int? @unique @map("legacy_id") legacyId Int? @unique @map("legacy_id")
@@ -902,14 +952,18 @@ model AuctionBid {
generalId Int @map("general_id") generalId Int @map("general_id")
amount Int amount Int
eventId String @map("event_id") eventId String @map("event_id")
/// Legacy/UI projection of occurredGameTick. Never use as expiry authority.
eventAt DateTime @map("event_at") eventAt DateTime @map("event_at")
occurredGameTick BigInt @map("occurred_game_tick")
requestedAtWall DateTime @map("requested_at_wall") @db.Timestamp(3)
meta Json @default(dbgenerated("'{}'::jsonb")) meta Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade) auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade)
@@index([auctionId, amount]) @@index([auctionId, amount])
@@index([auctionId, eventAt]) @@index([auctionId, eventAt])
@@index([auctionId, occurredGameTick])
@@map("auction_bid") @@map("auction_bid")
} }
@@ -0,0 +1,160 @@
-- Message envelopes are WALL_TIME. The existing time/valid_until columns are
-- retained as rolling-deploy projections while actionable gameplay state moves
-- to an explicit GAME_TIME record.
ALTER TABLE message
ADD COLUMN created_at_wall TIMESTAMP(3),
ADD COLUMN delete_until_wall TIMESTAMP(3),
ADD COLUMN tombstoned_at_wall TIMESTAMP(3),
ADD COLUMN occurred_game_tick BIGINT;
-- Historical rows predate a trustworthy wall-occurrence field. `time` is the
-- only available evidence, so preserve it as the best-effort occurrence while
-- ensuring the migration can never reopen an old five-minute delete window.
UPDATE message
SET created_at_wall = time,
delete_until_wall = LEAST(time + INTERVAL '5 minutes', CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
tombstoned_at_wall = CASE
WHEN lower(COALESCE(message->'option'->>'invalid', 'false')) IN ('true', '1')
THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
ELSE NULL
END,
occurred_game_tick = time_tick;
ALTER TABLE message
ALTER COLUMN created_at_wall SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
ALTER COLUMN created_at_wall SET NOT NULL,
ALTER COLUMN delete_until_wall SET DEFAULT ((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '5 minutes'),
ALTER COLUMN delete_until_wall SET NOT NULL;
CREATE INDEX message_mailbox_type_id_idx ON message(mailbox, type, id);
CREATE INDEX message_delete_until_wall_idx ON message(delete_until_wall);
CREATE TABLE message_action (
message_id INTEGER PRIMARY KEY REFERENCES message(id) ON DELETE CASCADE,
action_type VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
created_game_tick BIGINT NOT NULL,
expires_game_tick BIGINT,
resolved_game_tick BIGINT,
clock_revision BIGINT NOT NULL,
deadline_generation BIGINT NOT NULL,
created_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
updated_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
CONSTRAINT message_action_status_check CHECK (status IN ('PENDING', 'RESOLVED', 'CANCELLED')),
CONSTRAINT message_action_resolution_check CHECK (
(status = 'PENDING' AND resolved_game_tick IS NULL)
OR (status <> 'PENDING' AND resolved_game_tick IS NOT NULL)
)
);
-- Existing actionable payloads used message ticks as their GAME_TIME
-- authority. Backfill once; after this migration message_action is authoritative
-- and NULL never changes the clock domain of the rule.
INSERT INTO message_action (
message_id,
action_type,
status,
created_game_tick,
expires_game_tick,
resolved_game_tick,
clock_revision,
deadline_generation
)
SELECT
message.id,
message.message->'option'->>'action',
CASE
WHEN message.time_tick IS NULL
OR (message.valid_until < TIMESTAMP '9000-01-01' AND message.valid_until_tick IS NULL)
OR lower(COALESCE(message.message->'option'->>'used', 'false')) IN ('true', '1')
OR lower(COALESCE(message.message->'option'->>'invalid', 'false')) IN ('true', '1')
OR message.valid_until <= message.time
THEN 'RESOLVED'
ELSE 'PENDING'
END,
COALESCE(message.time_tick, 0),
CASE
WHEN message.valid_until_tick IS NULL
OR message.valid_until_tick >= 9007199254740991
THEN NULL
ELSE message.valid_until_tick
END,
CASE
WHEN message.time_tick IS NULL
OR (message.valid_until < TIMESTAMP '9000-01-01' AND message.valid_until_tick IS NULL)
OR lower(COALESCE(message.message->'option'->>'used', 'false')) IN ('true', '1')
OR lower(COALESCE(message.message->'option'->>'invalid', 'false')) IN ('true', '1')
OR message.valid_until <= message.time
THEN COALESCE(message.valid_until_tick, message.time_tick, 0)
ELSE NULL
END,
COALESCE((SELECT clock_revision FROM world_state ORDER BY id ASC LIMIT 1), 1),
COALESCE((SELECT deadline_generation FROM world_state ORDER BY id ASC LIMIT 1), 1)
FROM message
WHERE jsonb_typeof(message.message->'option') = 'object'
AND NULLIF(message.message->'option'->>'action', '') IS NOT NULL;
CREATE INDEX message_action_status_expires_game_tick_idx
ON message_action(status, expires_game_tick);
-- Inheritance requests are WALL_TIME receipts. Their input_event row remains
-- the durable command/effect state and owns the GAME clock fence coordinate.
CREATE TABLE inheritance_ledger (
id BIGSERIAL PRIMARY KEY,
request_id TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
action TEXT NOT NULL,
cost DOUBLE PRECISION NOT NULL,
status TEXT NOT NULL DEFAULT 'APPLIED',
requested_at_wall TIMESTAMP(3) NOT NULL,
consumed_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
applied_clock_revision BIGINT NOT NULL,
applied_deadline_generation BIGINT NOT NULL,
created_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
CONSTRAINT inheritance_ledger_status_check CHECK (status IN ('APPLIED')),
CONSTRAINT inheritance_ledger_cost_check CHECK (cost >= 0)
);
CREATE INDEX inheritance_ledger_user_id_id_idx ON inheritance_ledger(user_id, id);
CREATE INDEX inheritance_ledger_status_id_idx ON inheritance_ledger(status, id);
-- Auction bid receipt and gameplay occurrence are different facts. event_at is
-- retained as the GAME_TIME projection used by existing UI and ordering code.
ALTER TABLE auction_bid
ADD COLUMN requested_at_wall TIMESTAMP(3),
ADD COLUMN occurred_game_tick BIGINT;
UPDATE auction_bid AS bid
SET requested_at_wall = bid.created_at,
occurred_game_tick = ROUND(
EXTRACT(EPOCH FROM (bid.event_at - world.clock_base_time))
* (36000000::numeric / world.tick_seconds)
)::bigint
FROM world_state AS world;
ALTER TABLE auction_bid
ALTER COLUMN requested_at_wall SET NOT NULL,
ALTER COLUMN occurred_game_tick SET NOT NULL,
ALTER COLUMN created_at SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC');
CREATE INDEX auction_bid_auction_occurred_game_tick_idx
ON auction_bid(auction_id, occurred_game_tick);
-- Selection-pool reselection is expressed in turns. Preserve the old DateTime
-- keys only as projections and make one GAME_TIME authority explicit.
UPDATE general AS actor
SET meta = jsonb_set(
actor.meta,
'{next_change_tick}',
to_jsonb(ROUND(
EXTRACT(EPOCH FROM (
COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt')::timestamp
- world.clock_base_time
)) * (36000000::numeric / world.tick_seconds)
)::bigint),
true
)
FROM world_state AS world
WHERE COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt') IS NOT NULL
AND COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt')
~ '^\d{4}-\d{2}-\d{2}T';
@@ -48,6 +48,16 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여
- `select_npc_token`, `select_npc_token_valid_until_idx` - `select_npc_token`, `select_npc_token_valid_until_idx`
- `general_user_id_key` - `general_user_id_key`
## 시간 도메인 populated upgrade 검증
메시지 envelope의 WALL_TIME, actionable message와 선택 cooldown·경매의
GAME_TIME backfill, 유산 receipt table, 두 번째 deploy no-op을 전용 tmpfs
PostgreSQL에서 검증합니다. 영속 Docker volume은 만들지 않습니다.
```sh
pnpm --filter @sammo-ts/infra verify:migration:time-domains
```
검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유 검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유
database나 Compose volume을 삭제하지 않습니다. database나 Compose volume을 삭제하지 않습니다.
@@ -0,0 +1,166 @@
#!/bin/sh
set -eu
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
package_dir="$(dirname "$script_dir")"
prisma_dir="$package_dir/prisma"
target_migration=20260903140000_split_message_wall_and_game_time
task_label=devsam.core2026.time-domain-migration-preflight
run_id="$(date -u +%m%d%H%M%S)_$$"
container_name="sammo-time-domain-preflight-$run_id"
schema_name="time_domain_preflight_$run_id"
work_dir="$(mktemp -d /tmp/sammo-time-domain-preflight.XXXXXX)"
container_created=0
case "$container_name" in sammo-time-domain-preflight-[0-9]*_[0-9]*) ;; *) exit 64 ;; esac
case "$schema_name" in time_domain_preflight_[0-9]*_[0-9]*) ;; *) exit 64 ;; esac
cleanup() {
cleanup_failed=0
if [ "$container_created" -eq 1 ] && docker inspect "$container_name" >/dev/null 2>&1; then
actual_label="$(docker inspect --format '{{ index .Config.Labels "devsam.core2026.task" }}' "$container_name")"
if [ "$actual_label" != "$task_label" ]; then
echo "refusing to remove container with unexpected ownership label" >&2
cleanup_failed=1
elif ! docker rm -f "$container_name" >/dev/null; then
cleanup_failed=1
fi
fi
case "$work_dir" in
/tmp/sammo-time-domain-preflight.*) rm -r -- "$work_dir" || cleanup_failed=1 ;;
*) cleanup_failed=1 ;;
esac
return "$cleanup_failed"
}
handle_exit() {
exit_status=$?
trap - EXIT HUP INT TERM
if ! cleanup && [ "$exit_status" -eq 0 ]; then exit_status=1; fi
exit "$exit_status"
}
trap handle_exit EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 69; }
[ -d "$prisma_dir/migrations/$target_migration" ] || { echo "target migration is missing" >&2; exit 66; }
umask 077
password="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
password_file="$work_dir/postgres_password"
printf '%s\n' "$password" >"$password_file"
docker run -d \
--name "$container_name" \
--label "devsam.core2026.task=$task_label" \
--tmpfs /var/lib/postgresql:rw,nodev,nosuid,size=1g \
--mount "type=bind,source=$password_file,target=/run/secrets/postgres_password,readonly" \
-e POSTGRES_DB=sammo \
-e POSTGRES_USER=sammo \
-e POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \
-p 127.0.0.1::5432 \
postgres:18.4-bookworm >/dev/null
container_created=1
if [ -n "$(docker inspect --format '{{ range .Mounts }}{{ if eq .Type "volume" }}volume{{ end }}{{ end }}' "$container_name")" ]; then
echo "preflight container unexpectedly owns a Docker volume" >&2
exit 1
fi
attempt=0
until docker exec "$container_name" pg_isready -U sammo -d sammo >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 60 ]; then docker logs --tail 100 "$container_name" >&2; exit 1; fi
sleep 1
done
published_port="$(docker port "$container_name" 5432/tcp)"
published_port="${published_port##*:}"
case "$published_port" in ''|*[!0-9]*) exit 1 ;; esac
export POSTGRES_HOST=127.0.0.1
export POSTGRES_PORT="$published_port"
export POSTGRES_DB=sammo
export POSTGRES_USER=sammo
export POSTGRES_PASSWORD="$password"
export POSTGRES_SCHEMA="$schema_name"
unset DATABASE_URL DATABASE_SCHEMA
stage_prisma="$work_dir/prisma"
mkdir -p "$stage_prisma/migrations"
cp "$prisma_dir/game.prisma" "$stage_prisma/game.prisma"
found_target=0
for migration_dir in "$prisma_dir"/migrations/[0-9]*; do
migration_name="$(basename "$migration_dir")"
if [ "$migration_name" = "$target_migration" ]; then found_target=1; break; fi
cp -R "$migration_dir" "$stage_prisma/migrations/$migration_name"
done
[ "$found_target" -eq 1 ] || exit 1
cd "$package_dir"
PRISMA_SCHEMA="$stage_prisma/game.prisma" \
pnpm exec prisma migrate deploy --schema "$stage_prisma/game.prisma" >"$work_dir/predecessor.log"
docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo >/dev/null <<SQL
SET search_path TO "$schema_name";
INSERT INTO world_state (
scenario_code, current_year, current_month, tick_seconds,
clock_base_time, clock_tick, clock_wall_anchor, last_turn_tick, updated_at
) VALUES (
'time-domain-fixture', 200, 1, 600,
TIMESTAMP '0200-01-01 00:00:00', 36000000, TIMESTAMP '2026-09-03 00:00:00', 36000000,
TIMESTAMP '2026-09-03 00:00:00'
);
INSERT INTO general (id, name, turn_time, meta)
VALUES (
910001,
'시간장수',
TIMESTAMP '0200-01-01 00:10:00',
jsonb_build_object(
'next_change', '0200-01-01T00:30:00.000Z',
'nextChangeAt', '0200-01-01T00:30:00.000Z'
)
);
INSERT INTO message (id, mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
VALUES
(920001, 0, 'global', 1, 0, TIMESTAMP '0200-01-01 00:00:00', 36000000,
TIMESTAMP '9999-12-31 00:00:00', 9007199254740991,
jsonb_build_object('text', 'normal')),
(920002, 1, 'private', 1, 2, TIMESTAMP '0200-01-01 00:05:00', 54000000,
TIMESTAMP '0200-01-01 01:00:00', 252000000,
jsonb_build_object('option', jsonb_build_object('action', 'raiseInvader', 'used', false))),
(920003, 2, 'private', 1, 2, TIMESTAMP '0200-01-01 00:06:00', NULL,
TIMESTAMP '0200-01-01 01:00:00', NULL,
jsonb_build_object('option', jsonb_build_object('action', 'scout', 'used', false)));
INSERT INTO auction (id, type, host_general_id, status, close_at, open_tick, close_tick)
VALUES (930001, 'UNIQUE_ITEM', 910001, 'OPEN', TIMESTAMP '0200-01-01 01:00:00', 36000000, 252000000);
INSERT INTO auction_bid (id, auction_id, general_id, amount, event_id, event_at, created_at)
VALUES (930002, 930001, 910001, 100, 'time-domain-bid', TIMESTAMP '0200-01-01 00:10:00',
TIMESTAMP '2026-09-03 01:02:03.456');
SQL
PRISMA_SCHEMA="$prisma_dir/game.prisma" \
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$work_dir/target.log"
PRISMA_SCHEMA="$prisma_dir/game.prisma" \
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$work_dir/noop.log"
grep -Fq 'No pending migrations to apply' "$work_dir/noop.log"
result="$(docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo -tAc "
SET search_path TO \"$schema_name\";
SELECT
(SELECT count(*) FROM message_action) = 2
AND (SELECT action_type = 'raiseInvader' AND status = 'PENDING' AND expires_game_tick = 252000000
FROM message_action WHERE message_id = 920002)
AND (SELECT status = 'RESOLVED' AND resolved_game_tick = 0
FROM message_action WHERE message_id = 920003)
AND (SELECT requested_at_wall = TIMESTAMP '2026-09-03 01:02:03.456'
AND occurred_game_tick = 36000000
FROM auction_bid WHERE id = 930002)
AND (SELECT (meta->>'next_change_tick')::bigint = 108000000 FROM general WHERE id = 910001)
AND (SELECT delete_until_wall <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC' FROM message WHERE id = 920001)
AND to_regclass('\"$schema_name\".inheritance_ledger') IS NOT NULL;
" | tail -n 1)"
[ "$result" = "t" ] || { echo "time-domain migration assertions failed: $result" >&2; exit 1; }
echo "time-domain populated migration and no-op redeploy passed"
+2
View File
@@ -13,6 +13,7 @@ export interface DatabaseClient {
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate; trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
messageReadState: GamePrisma.MessageReadStateDelegate; messageReadState: GamePrisma.MessageReadStateDelegate;
message: GamePrisma.MessageDelegate; message: GamePrisma.MessageDelegate;
messageAction: GamePrisma.MessageActionDelegate;
city: GamePrisma.CityDelegate; city: GamePrisma.CityDelegate;
nation: GamePrisma.NationDelegate; nation: GamePrisma.NationDelegate;
diplomacy: GamePrisma.DiplomacyDelegate; diplomacy: GamePrisma.DiplomacyDelegate;
@@ -38,6 +39,7 @@ export interface DatabaseClient {
nationBetting: GamePrisma.NationBettingDelegate; nationBetting: GamePrisma.NationBettingDelegate;
nationBet: GamePrisma.NationBetDelegate; nationBet: GamePrisma.NationBetDelegate;
inheritanceLog: GamePrisma.InheritanceLogDelegate; inheritanceLog: GamePrisma.InheritanceLogDelegate;
inheritanceLedger: GamePrisma.InheritanceLedgerDelegate;
inheritanceResult: GamePrisma.InheritanceResultDelegate; inheritanceResult: GamePrisma.InheritanceResultDelegate;
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate; inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
boardPost: GamePrisma.BoardPostDelegate; boardPost: GamePrisma.BoardPostDelegate;

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