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

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
+45 -28
View File
@@ -60,27 +60,27 @@ export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null },
now: Date,
nowTick: number | null
): boolean =>
auction.closeTick !== null && nowTick !== null
? auction.closeTick < BigInt(nowTick)
: auction.closeAt.getTime() < now.getTime();
): boolean => {
void now;
return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
};
export const resolveAuctionBidTiming = (
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
processingNow: Date,
acceptedGameTick?: number
): { bidAt: Date; bidTick: number } =>
acceptedGameTick === undefined
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) }
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick };
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingGameTick: number
): { bidAt: Date; bidTick: number } => {
if (!Number.isSafeInteger(processingGameTick)) {
throw new Error('Auction bid requires an authoritative daemon processing game tick.');
}
return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
};
export const hasAuctionBidClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null },
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
processingNow: Date,
acceptedGameTick?: number
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingGameTick: number
): boolean => {
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick);
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
return hasAuctionClosePassed(auction, bidAt, bidTick);
};
@@ -268,15 +268,15 @@ export const createAuctionBidder = async (options: {
reason: '경매가 종료되었습니다.',
};
}
const processingNow = world.getGameNow(new Date());
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
const { bidAt, bidTick } = resolveAuctionBidTiming(
world,
processingNow,
typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick)
? convertedProcessingTick
: command.acceptedGameTick
);
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
throw new Error('auctionBid requires an authoritative daemon processing game tick.');
}
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
throw new Error('auctionBid requires its durable input-event wall occurrence.');
}
const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick);
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
return {
type: 'auctionBid',
@@ -510,13 +510,24 @@ export const createAuctionBidder = async (options: {
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
await tx.$executeRaw(
GamePrisma.sql`
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
INSERT INTO auction_bid (
auction_id,
general_id,
amount,
event_id,
event_at,
occurred_game_tick,
requested_at_wall,
meta
)
VALUES (
${command.auctionId},
${command.generalId},
${command.amount},
${eventId},
${eventAt},
${BigInt(bidTick)},
${requestedAtWall},
${JSON.stringify({
tryExtendCloseDate: command.tryExtendCloseDate ?? true,
...(auction.type === 'UNIQUE_ITEM'
@@ -536,7 +547,7 @@ export const createAuctionBidder = async (options: {
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
latest_event_id = ${eventId},
latest_event_at = ${eventAt},
updated_at = ${eventAt}
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${command.auctionId}
AND status = 'OPEN'
AND latest_event_id = ${auction.latestEventId}
@@ -556,7 +567,7 @@ export const createAuctionBidder = async (options: {
GamePrisma.sql`
UPDATE inheritance_point
SET value = value - ${morePoint},
updated_at = ${eventAt}
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE user_id = ${userId}
AND key = 'previous'
AND value >= ${morePoint}
@@ -587,11 +598,16 @@ export const createAuctionBidder = async (options: {
await tx.$executeRaw(
GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at)
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt})
VALUES (
${prevUserId},
'previous',
${highestBid.amount},
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT (user_id, key)
DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
`
);
await tx.$executeRaw(
@@ -711,6 +727,7 @@ export const createAuctionBidder = async (options: {
ok: true,
auctionId: command.auctionId,
closeAt: nextCloseAt.toISOString(),
closeTick: world.dateToGameTick(nextCloseAt),
};
},
close: async (): Promise<void> => {
+15 -16
View File
@@ -91,21 +91,17 @@ export const isAuctionFinalizeGenerationCurrent = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
): boolean => {
if (command.expectedCloseTick !== undefined) {
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
}
if (command.expectedCloseAt !== undefined) {
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
}
return true;
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
};
export const hasAuctionFinalizeDeadlineArrived = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
now: Date,
nowTick: number
): boolean =>
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick);
): boolean => {
void now;
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
};
export const buildAuctionBidderSystemMessage = (options: {
bidder: TurnGeneral;
@@ -293,7 +289,11 @@ export const createAuctionFinalizer = async (options: {
return { type: 'auctionFinalize', ok: true, auctionId };
}
const now = world.getGameNow(new Date());
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionFinalize requires an authoritative daemon processing game tick.');
}
const now = world.gameTickToDate(processingGameTick);
if (auction.status === 'OPEN') {
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
return {
@@ -303,8 +303,7 @@ export const createAuctionFinalizer = async (options: {
reason: '경매 마감 세대가 변경되었습니다.',
};
}
const nowTick = world.dateToGameTick(now);
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
return {
type: 'auctionFinalize',
ok: false,
@@ -316,8 +315,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql`
UPDATE auction
SET status = 'FINALIZING',
finalizing_at = ${now},
updated_at = ${now}
finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId}
AND status = 'OPEN'
`
@@ -364,8 +363,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql`
UPDATE auction
SET status = ${status},
finished_at = ${now},
updated_at = ${now}
finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId}
`
);
+20 -4
View File
@@ -94,7 +94,11 @@ const openResourceAuction = async (
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
}
const now = world.getGameNow(new Date());
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
}
const now = world.gameTickToDate(processingGameTick);
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
const auction = await db.auction.create({
@@ -113,7 +117,7 @@ const openResourceAuction = async (
},
status: 'OPEN',
closeAt,
openTick: BigInt(world.dateToGameTick(now)),
openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)),
},
});
@@ -125,6 +129,7 @@ const openResourceAuction = async (
ok: true,
auctionId: auction.id,
closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
};
};
@@ -220,8 +225,16 @@ const openUniqueAuction = async (
}
const state = world.getState();
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
}
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
throw new Error('auctionOpen requires its durable input-event wall occurrence.');
}
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
const now = world.getGameNow(new Date());
const now = world.gameTickToDate(processingGameTick);
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
const extensionLimitMinutes = Math.max(
@@ -253,7 +266,7 @@ const openUniqueAuction = async (
},
status: 'OPEN',
closeAt,
openTick: BigInt(world.dateToGameTick(now)),
openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)),
latestEventId: eventId,
latestEventAt: now,
@@ -263,6 +276,8 @@ const openUniqueAuction = async (
amount: command.amount,
eventId,
eventAt: now,
occurredGameTick: BigInt(processingGameTick),
requestedAtWall,
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
},
},
@@ -308,6 +323,7 @@ const openUniqueAuction = async (
ok: true,
auctionId: auction.id,
closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
};
};
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { performance } from 'node:perf_hooks';
import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra';
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
import type {
@@ -10,8 +11,9 @@ import type {
TurnDaemonStatus,
} from './types.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const serializeResult = (value: unknown): string =>
JSON.stringify(value, (_key, item: unknown) => (typeof item === 'bigint' ? item.toString() : item)) ?? 'null';
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
private readonly localQueue: TurnDaemonCommand[] = [];
@@ -35,8 +37,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
return local.concat(remote);
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
while (deadlineMs === null || Date.now() < deadlineMs) {
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs);
while (deadline === null || performance.now() < deadline) {
const local = this.localQueue.shift();
if (local) {
return local;
@@ -45,7 +48,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
if (remote[0]) {
return remote[0];
}
const remaining = deadlineMs === null ? 100 : Math.max(1, Math.min(100, deadlineMs - Date.now()));
const remaining = deadline === null ? 100 : Math.max(1, Math.min(100, deadline - performance.now()));
await delay(remaining);
}
return null;
@@ -84,35 +87,37 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
return;
}
const terminal = event.attempts >= this.maxAttempts;
await transaction.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
attempts: event.attempts,
},
data: {
status: terminal ? 'FAILED' : 'PENDING',
processingAt: null,
lockedBy: null,
leaseUntil: null,
completedAt: terminal ? new Date() : null,
result: GamePrisma.DbNull,
error: message,
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus",
processing_at = NULL,
locked_by = NULL,
lease_until = NULL,
completed_at = CASE
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
ELSE NULL
END,
result = NULL,
error = ${message}
WHERE request_id = ${requestId}
AND target = 'ENGINE'::"InputEventTarget"
AND status = 'PROCESSING'::"InputEventStatus"
AND locked_by = ${this.workerId}
AND attempts = ${event.attempts}
`);
});
}
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
await this.recoverExpiredLeases();
return this.db.$transaction(async (transaction) => {
const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
});
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
const currentRevision = world?.clockRevision ?? null;
const rows = await transaction.$queryRaw<
Array<{
@@ -141,6 +146,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
AND (
${gameplayAllowed}
OR "event_type" = 'getStatus'
OR (
${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
)
OR (
${world?.clockPhase === 'SUSPENDED'}
AND "event_type" = 'messageRespond'
@@ -148,8 +158,14 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
AND EXISTS (
SELECT 1
FROM "message" AS pending_message
JOIN "message_action" AS pending_action
ON pending_action."message_id" = pending_message."id"
WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer
AND pending_message."message" #>> '{option,action}' = 'raiseInvader'
AND pending_action."action_type" = 'raiseInvader'
AND pending_action."status" = 'PENDING'
AND pending_action."clock_revision" = ${currentRevision}
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
)
AND EXISTS (
SELECT 1
@@ -175,9 +191,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
})
: [];
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) {
return row.acceptedGameTick ?? world?.clockTick ?? null;
return claimCoordinate.gameTick;
}
if (row.acceptedClockRevision > currentRevision) return undefined;
let revision = row.acceptedClockRevision;
@@ -197,19 +213,25 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
entry.processingGameTick !== undefined
);
for (const { row, processingGameTick } of processableRows) {
await transaction.inputEvent.update({
where: { sequence: row.sequence },
data: {
status: 'PROCESSING',
processingAt: new Date(),
processingGameTick,
processingClockRevision: currentRevision,
processingDeadlineGeneration: world?.deadlineGeneration ?? null,
lockedBy: this.workerId,
leaseUntil: new Date(Date.now() + this.leaseDurationMs),
attempts: { increment: 1 },
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'PROCESSING'::"InputEventStatus",
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
accepted_game_tick = COALESCE(accepted_game_tick, ${processingGameTick}),
accepted_clock_revision = COALESCE(accepted_clock_revision, ${currentRevision}),
accepted_deadline_generation = COALESCE(
accepted_deadline_generation,
${world?.deadlineGeneration ?? null}
),
processing_game_tick = ${processingGameTick},
processing_clock_revision = ${currentRevision},
processing_deadline_generation = ${world?.deadlineGeneration ?? null},
locked_by = ${this.workerId},
lease_until = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${this.leaseDurationMs} * INTERVAL '1 millisecond',
attempts = attempts + 1
WHERE sequence = ${row.sequence}
`);
}
const commands: TurnDaemonCommand[] = [];
@@ -220,39 +242,34 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
command: row.payload as TurnDaemonCommand,
});
if (!command) {
await transaction.inputEvent.update({
where: { sequence: row.sequence },
data: {
status: 'FAILED',
error: `Invalid command payload for ${row.eventType}`,
completedAt: new Date(),
lockedBy: null,
leaseUntil: null,
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'FAILED'::"InputEventStatus",
error = ${`Invalid command payload for ${row.eventType}`},
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
locked_by = NULL,
lease_until = NULL
WHERE sequence = ${row.sequence}
`);
continue;
}
if (
processingGameTick !== null &&
row.acceptedGameTick !== null &&
processingGameTick !== row.acceptedGameTick
) {
if (processingGameTick !== null) {
const value = Number(processingGameTick);
if (!Number.isSafeInteger(value)) {
await transaction.inputEvent.update({
where: { sequence: row.sequence },
data: {
status: 'FAILED',
error: 'Converted processing game tick is outside the safe integer range.',
completedAt: new Date(),
lockedBy: null,
leaseUntil: null,
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'FAILED'::"InputEventStatus",
error = 'Converted processing game tick is outside the safe integer range.',
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
locked_by = NULL,
lease_until = NULL
WHERE sequence = ${row.sequence}
`);
continue;
}
Reflect.set(command, 'processingGameTick', value);
}
Reflect.set(command, 'requestedAtWall', row.createdAt);
commands.push(command);
}
return commands;
@@ -260,23 +277,20 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
}
private async complete(requestId: string, result: unknown): Promise<void> {
const completed = await this.db.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
},
data: {
status: 'SUCCEEDED',
result: asJson(result),
completedAt: new Date(),
error: null,
lockedBy: null,
leaseUntil: null,
},
});
if (completed.count > 0) {
const completed = await this.db.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'SUCCEEDED'::"InputEventStatus",
result = CAST(${serializeResult(result)} AS jsonb),
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
error = NULL,
locked_by = NULL,
lease_until = NULL
WHERE request_id = ${requestId}
AND target = 'ENGINE'::"InputEventTarget"
AND status = 'PROCESSING'::"InputEventStatus"
AND locked_by = ${this.workerId}
`);
if (completed > 0) {
return;
}
// Database hooks commit mutation results atomically with game state and
@@ -297,19 +311,15 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
}
private async recoverExpiredLeases(): Promise<void> {
const now = new Date();
await this.db.inputEvent.updateMany({
where: {
target: 'ENGINE',
status: 'PROCESSING',
leaseUntil: { lt: now },
},
data: {
status: 'PENDING',
processingAt: null,
lockedBy: null,
leaseUntil: null,
},
});
await this.db.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'PENDING'::"InputEventStatus",
processing_at = NULL,
locked_by = NULL,
lease_until = NULL
WHERE target = 'ENGINE'::"InputEventTarget"
AND status = 'PROCESSING'::"InputEventStatus"
AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
`);
}
}
@@ -1,7 +1,7 @@
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
type Waiter = {
deadlineMs: number | null;
timeoutMs: number | null;
resolve: (command: TurnDaemonCommand | null) => void;
timeoutId?: ReturnType<typeof setTimeout>;
};
@@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue {
return drained;
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
if (this.queue.length > 0) {
return this.queue.shift() ?? null;
}
return new Promise((resolve) => {
const waiter: Waiter = { deadlineMs, resolve };
if (deadlineMs !== null) {
const delay = Math.max(0, deadlineMs - Date.now());
const waiter: Waiter = { timeoutMs, resolve };
if (timeoutMs !== null) {
const delay = Math.max(0, timeoutMs);
waiter.timeoutId = setTimeout(() => {
this.removeWaiter(waiter);
resolve(null);
@@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur
return drained.concat(remote);
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
if (this.localQueue.length > 0) {
return this.localQueue.shift() ?? null;
}
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now());
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000);
if (deadlineMs !== null && blockMs === 0) {
const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs);
const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000);
if (timeoutMs !== null && blockMs === 0) {
return null;
}
@@ -222,7 +222,7 @@ export class TurnDaemonLifecycle {
continue;
}
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs));
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
if (command) {
await this.handleCommand(command);
}
@@ -268,7 +268,7 @@ export class TurnDaemonLifecycle {
}
private async waitForResume(): Promise<void> {
const command = await this.controlQueue.waitUntil(null);
const command = await this.controlQueue.waitFor(null);
if (command) {
await this.handleCommand(command);
}
+1 -1
View File
@@ -82,7 +82,7 @@ export interface TurnStateStore {
export interface TurnDaemonControlQueue {
enqueue(command: TurnDaemonCommand): void;
drain(): Promise<TurnDaemonCommand[]>;
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>;
waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null>;
getDepth(): number;
}
@@ -18,6 +18,10 @@ interface MessageRow {
type: string;
time: Date;
validUntil: Date;
actionType: string;
actionStatus: string;
createdGameTick: bigint;
expiresGameTick: bigint | null;
message: unknown;
}
@@ -98,11 +102,16 @@ const invalidateMessageIds = async (
): Promise<void> => {
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
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({
where: { id: { in: uniqueIds } },
data: {
validUntil: now,
validUntilTick: BigInt(world.dateToGameTick(now)),
validUntilTick: resolvedGameTick,
},
});
};
@@ -113,40 +122,53 @@ const validateActor = async (options: {
requestId?: string;
userId: string;
generalId: number;
}): Promise<Date> => {
}): Promise<{ processingGameTick: number }> => {
const actor = options.world.getGeneralById(options.generalId);
if (!actor || actor.userId !== options.userId) {
throw new Error('messageRespond general owner does not match command user.');
}
if (!options.requestId) return new Date();
if (!options.requestId) {
throw new Error('messageRespond requires a durable ENGINE input event requestId.');
}
const event = await options.db.inputEvent.findUnique({
where: { requestId: options.requestId },
select: { actorUserId: true, target: true, eventType: true, createdAt: true },
select: { actorUserId: true, target: true, eventType: true, processingGameTick: true },
});
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
throw new Error('ENGINE input event actor or type does not match messageRespond.');
}
return event.createdAt;
const processingGameTick = event.processingGameTick;
if (processingGameTick === null || !Number.isSafeInteger(Number(processingGameTick))) {
throw new Error('messageRespond requires an authoritative processing game tick.');
}
return { processingGameTick: Number(processingGameTick) };
};
const fetchMessageForUpdate = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
messageId: number,
now: Date
currentGameTick: number
): Promise<MessageRow | null> => {
const currentTick = BigInt(world.dateToGameTick(now));
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
FROM message
WHERE id = ${messageId}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
OR (valid_until_tick IS NULL AND valid_until > ${now})
)
SELECT
envelope.id,
envelope.mailbox,
envelope.type,
envelope.time,
envelope.valid_until AS "validUntil",
action.action_type AS "actionType",
action.status AS "actionStatus",
action.created_game_tick AS "createdGameTick",
action.expires_game_tick AS "expiresGameTick",
envelope.message
FROM message AS envelope
JOIN message_action AS action ON action.message_id = envelope.id
WHERE envelope.id = ${messageId}
AND action.status = 'PENDING'
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(currentGameTick)})
LIMIT 1
FOR UPDATE
FOR UPDATE OF envelope, action
`);
return rows[0] ?? null;
};
@@ -165,7 +187,7 @@ const respondToScout = async (options: {
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
}
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) {
if (row.actionStatus !== 'PENDING' || row.actionType !== 'scout' || isLegacyTruthy(asRecord(payload.option).used)) {
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
}
@@ -186,18 +208,17 @@ const respondToScout = async (options: {
}
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
SELECT id
FROM message
WHERE mailbox = ${payload.src.generalId}
AND type = 'private'
AND dest = mailbox
AND id <> ${row.id}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
OR (valid_until_tick IS NULL AND valid_until > ${now})
)
AND message->'option'->>'action' = 'scout'
FOR UPDATE
SELECT envelope.id
FROM message AS envelope
JOIN message_action AS action ON action.message_id = envelope.id
WHERE envelope.mailbox = ${payload.src.generalId}
AND envelope.type = 'private'
AND envelope.dest = envelope.mailbox
AND envelope.id <> ${row.id}
AND action.status = 'PENDING'
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
AND action.action_type = 'scout'
FOR UPDATE OF envelope, action
`);
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
world.queueMessage({
@@ -327,6 +348,7 @@ const respondToRaiseInvader = async (options: {
},
event
);
await invalidateMessageIds(db, world, [row.id], world.gameTickToDate(alignment.alignedTick));
return { ok: true, action: 'raiseInvader', reason: 'success' };
};
@@ -354,13 +376,14 @@ export const respondToActionableMessage = async (options: {
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
}) => Promise<ClockReconciliationResult>;
}): Promise<ActionableMessageResponseResult> => {
const acceptedAt = await validateActor(options);
const now = options.world.getGameNow(acceptedAt);
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
const accepted = await validateActor(options);
const now = options.world.gameTickToDate(accepted.processingGameTick);
const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
const payload = parsePayload(row.message);
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
const action = asRecord(payload.option).action;
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
if (action === 'scout') {
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
}
+131 -27
View File
@@ -147,7 +147,7 @@ const lockWorld = async (db: GamePrisma.TransactionClient): Promise<number> => {
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 auction
@@ -155,10 +155,18 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigin
ORDER BY id FOR UPDATE
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM message
WHERE valid_until_tick IS NOT NULL AND valid_until_tick >= ${cutTick}
ORDER BY id FOR UPDATE
SELECT bid.id
FROM auction_bid AS bid
JOIN auction ON auction.id = bid.auction_id
WHERE auction.status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
ORDER BY bid.id FOR UPDATE OF bid
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT message_id AS id FROM message_action
WHERE status = 'PENDING'
ORDER BY message_id FOR UPDATE
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM inheritance_ledger ORDER BY id FOR UPDATE`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
`);
@@ -175,7 +183,8 @@ const readParticipantSnapshots = async (
worldStateId: number,
cutTick: bigint
): 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({
where: { id: worldStateId },
select: {
@@ -188,17 +197,32 @@ const readParticipantSnapshots = async (
}),
db.general.findMany({
orderBy: { id: 'asc' },
select: { id: true, turnTick: true, recentWarTick: true },
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
}),
db.auction.findMany({
where: { status: { in: ['OPEN', 'FINALIZING'] } },
orderBy: { id: 'asc' },
select: { id: true, status: true, openTick: true, closeTick: true },
}),
db.message.findMany({
where: { validUntilTick: { not: null, gte: cutTick } },
db.auctionBid.findMany({
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
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({
where: { closedAt: null },
@@ -219,7 +243,7 @@ const readParticipantSnapshots = async (
orderBy: { sequence: 'asc' },
select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true },
}),
]);
]);
const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({
key,
policy,
@@ -246,6 +270,18 @@ const readParticipantSnapshots = async (
'KEEP',
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick }))
),
snapshot(
'selection-reselection-deadline',
'SHIFT',
generals.flatMap(({ id, meta: generalMeta }) => {
const raw =
generalMeta && typeof generalMeta === 'object' && !Array.isArray(generalMeta)
? Reflect.get(generalMeta, 'next_change_tick')
: null;
const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
})
),
snapshot(
'auction-open-occurrence',
'KEEP',
@@ -256,21 +292,34 @@ const readParticipantSnapshots = async (
'SHIFT',
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
),
snapshot('auction-bid-occurrence', 'KEEP', auctionBids),
snapshot(
'auction-finalizing-recovery',
'REBUILD',
auctions.map(({ id, status }) => ({ id, status }))
),
snapshot(
'message-occurrence',
'message-action-occurrence',
'KEEP',
messages.map(({ id, timeTick }) => ({ id, timeTick }))
messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick }))
),
snapshot(
'message-expiry',
'message-action-expiry',
'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(
'vote-start-occurrence',
'KEEP',
@@ -283,7 +332,7 @@ const readParticipantSnapshots = async (
),
snapshot('select-pool-reservation', 'SHIFT', pool),
snapshot('npc-selection-window', 'SHIFT', npcTokens),
snapshot('accepted-command-coordinate', 'KEEP', commands),
snapshot('daemon-command-coordinate', 'KEEP', commands),
snapshot('movable-json-rule-anchors', 'SHIFT', [
{
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 shift = BigInt(shiftTicks);
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.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
FROM general
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
`),
db.auction.aggregate({
_max: { closeTick: true },
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
}),
db.message.aggregate({
_max: { validUntilTick: true },
where: { validUntilTick: { not: null, lt: BigInt(MAX_SAFE_GAME_TICK) } },
db.messageAction.aggregate({
_max: { expiresGameTick: true },
where: { status: 'PENDING', expiresGameTick: { not: null } },
}),
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
db.selectPoolEntry.aggregate({
@@ -452,8 +506,9 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
]);
const values: Array<[string, bigint | null]> = [
['general.turn_tick', general._max.turnTick],
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
['auction.close_tick', auction._max.closeTick],
['message.valid_until_tick', message._max.validUntilTick],
['message_action.expires_game_tick', message._max.expiresGameTick],
['vote_poll.end_tick', vote._max.endTick],
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
@@ -493,6 +548,32 @@ const applyParticipantShift = async (
WHERE turn_tick IS NOT NULL
`)
);
affected.set(
'selection-reselection-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE general
SET meta = jsonb_set(
jsonb_set(
jsonb_set(
meta,
'{next_change_tick}',
to_jsonb((meta->>'next_change_tick')::bigint + ${shiftTicks}),
true
),
'{next_change}',
to_jsonb(((meta->>'next_change')::timestamp
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
true
),
'{nextChangeAt}',
to_jsonb(((meta->>'nextChangeAt')::timestamp
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
true
)
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
`)
);
affected.set(
'auction-deadline',
await db.$executeRaw(GamePrisma.sql`
@@ -504,14 +585,37 @@ const applyParticipantShift = async (
`)
);
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`
UPDATE message
SET valid_until_tick = valid_until_tick + ${shiftTicks},
valid_until = valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
WHERE valid_until_tick IS NOT NULL
AND valid_until_tick >= ${cutTick}
AND valid_until_tick < ${BigInt(MAX_SAFE_GAME_TICK)}
WITH shifted AS (
UPDATE message_action
SET expires_game_tick = expires_game_tick + ${shiftTicks},
clock_revision = ${targetRevision},
deadline_generation = ${targetGeneration},
updated_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE status = 'PENDING'
AND expires_game_tick IS NOT NULL
AND expires_game_tick >= ${cutTick}
RETURNING message_id, expires_game_tick
)
UPDATE message AS envelope
SET valid_until_tick = shifted.expires_game_tick,
valid_until = envelope.valid_until
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
FROM shifted
WHERE envelope.id = shifted.message_id
`)
);
affected.set(
+2 -3
View File
@@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({
type: z.literal('auctionFinalize'),
auctionId: zFiniteNumber,
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
expectedCloseTick: zSafeInteger.optional(),
expectedCloseTick: zSafeInteger,
});
const zAuctionOpen = z.object({
@@ -60,7 +60,6 @@ const zAuctionBid = z.object({
auctionId: zFiniteNumber,
generalId: zFiniteNumber,
amount: zFiniteNumber,
acceptedGameTick: zSafeInteger.optional(),
tryExtendCloseDate: z.boolean().optional(),
});
@@ -427,7 +426,7 @@ const zSelectPoolReserve = z
requestId: z.string().optional(),
userId: z.string().min(1),
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp),
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
})
.strict();
+13 -31
View File
@@ -7,6 +7,7 @@ import {
writeReadModelChangeJournal,
enqueuePrivateMessageWebPush,
enqueueWebPushOutboxEvents,
persistMessageEnvelope,
type InputJsonValue,
type ReadModelJournalWriteResult,
type TurnEngineCityUpdateInput,
@@ -1862,37 +1863,18 @@ export const createDatabaseTurnHooks = async (
await sendMessage(
{
insertMessage: async (draft: MessageRecordDraft) => {
const toTickOrNull = (date: Date): bigint | null => {
try {
return BigInt(world.dateToGameTick(date));
} catch {
// Legacy messages may use year 9999 as an
// effectively-unbounded expiry, beyond the
// safe JavaScript tick range.
return null;
}
};
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (
mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message
)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${toTickOrNull(draft.time)},
${draft.validUntil},
${toTickOrNull(draft.validUntil)},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) {
throw new Error('Failed to persist turn message.');
}
const clock = world.getGameClockState();
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
const expiresGameTick =
typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000
? null
: BigInt(world.dateToGameTick(draft.validUntil));
const id = await persistMessageEnvelope(prisma, draft, {
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
clockRevision: BigInt(clock.revision),
deadlineGeneration: BigInt(clock.deadlineGeneration),
expiresGameTick,
});
await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox);
return id;
+23 -18
View File
@@ -1,4 +1,4 @@
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
import { createGatewayPostgresConnector, GatewayPrisma } from '@sammo-ts/infra';
import { isRecord } from '@sammo-ts/common';
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
@@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async (
continue;
}
const terminal = result.status !== 'PARTIAL';
const updated = await prisma.gatewayRuntimeAction.updateMany({
where: {
id: action.id,
status: { in: ['REQUESTED', 'PARTIAL'] },
},
data: {
status: result.status,
detail: result.detail ?? null,
handler: 'turn-daemon',
handledAt: terminal ? new Date() : null,
attempts: { increment: 1 },
nextAttemptAt: terminal
? null
: new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))),
},
});
if (terminal && updated.count > 0) {
const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6));
const updated = await prisma.$queryRaw<Array<{ id: string }>>(GatewayPrisma.sql`
UPDATE gateway_runtime_action
SET status = ${result.status}::"GatewayRuntimeActionStatus",
detail = ${result.detail ?? null},
handler = 'turn-daemon',
handled_at = CASE
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
ELSE NULL
END,
attempts = attempts + 1,
next_attempt_at = CASE
WHEN ${terminal} THEN NULL
ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${retryDelayMs} * INTERVAL '1 millisecond'
END,
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${action.id}
AND status IN ('REQUESTED'::"GatewayRuntimeActionStatus", 'PARTIAL'::"GatewayRuntimeActionStatus")
RETURNING id
`);
if (terminal && updated.length > 0) {
await options.onActionApplied?.(actionRecord, result);
}
}
@@ -1,3 +1,5 @@
import { performance } from 'node:perf_hooks';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
@@ -42,7 +44,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return {
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
async shouldPause(): Promise<boolean> {
const now = Date.now();
const now = performance.now();
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
return cachedPause;
}
@@ -1,3 +1,5 @@
import { performance } from 'node:perf_hooks';
import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { InMemoryTurnWorld, TurnCalendarContext } from './inMemoryWorld.js';
@@ -50,16 +52,16 @@ export class InMemoryTurnProcessor implements TurnProcessor {
}
async run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise<TurnRunResult> {
const startMs = Date.now();
const startMs = performance.now();
const deadlineMs = startMs + Math.max(0, budget.budgetMs);
const isBudgetExpired = () => Date.now() >= deadlineMs;
const isBudgetExpired = () => performance.now() >= deadlineMs;
if (isWorldUnited(this.world)) {
return {
lastTurnTime: this.world.getState().lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: Math.max(0, Date.now() - startMs),
durationMs: Math.max(0, performance.now() - startMs),
partial: false,
checkpoint,
};
@@ -171,7 +173,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
lastTurnTime,
processedGenerals,
processedTurns,
durationMs: Math.max(0, Date.now() - startMs),
durationMs: Math.max(0, performance.now() - startMs),
partial,
checkpoint: nextCheckpoint,
};
@@ -310,7 +310,7 @@ export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
return '알수없음';
};
export const executeInheritanceAction = async (options: {
const executeInheritanceActionMutation = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
command: InheritanceActionCommand;
@@ -668,3 +668,59 @@ export const executeInheritanceAction = async (options: {
});
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost };
};
/**
* Persists the WALL_TIME inheritance receipt in the same transaction as the
* point debit, game mutation, and input-event completion. The input_event row
* is the durable retry/failure record and owns the authoritative GAME clock
* coordinate; an immediate effect does not invent a separate applied tick.
*/
export const executeInheritanceAction = async (options: {
db: GamePrisma.TransactionClient;
world: InMemoryTurnWorld;
command: InheritanceActionCommand;
gameNow: Date;
}): Promise<InheritanceActionResult> => {
const result = await executeInheritanceActionMutation(options);
if (!result.ok || !options.command.requestId) return result;
const event = await options.db.inputEvent.findUnique({
where: { requestId: options.command.requestId },
select: {
actorUserId: true,
target: true,
eventType: true,
createdAt: true,
processingClockRevision: true,
processingDeadlineGeneration: true,
},
});
if (
!event ||
event.actorUserId !== options.command.userId ||
event.target !== 'ENGINE' ||
event.eventType !== 'inheritanceAction' ||
event.processingClockRevision === null ||
event.processingDeadlineGeneration === null
) {
throw new Error('Inheritance ledger requires the authoritative ENGINE input-event clock fence.');
}
const previousPoint = await lockPreviousPoint(options.db, options.command.userId);
const cost = previousPoint - result.remainPoint;
if (!Number.isFinite(cost) || cost < 0) {
throw new Error(`Inheritance ledger calculated an invalid cost: ${cost}.`);
}
await options.db.inheritanceLedger.create({
data: {
requestId: options.command.requestId,
userId: options.command.userId,
action: result.action,
cost,
status: 'APPLIED',
requestedAtWall: event.createdAt,
appliedClockRevision: event.processingClockRevision,
appliedDeadlineGeneration: event.processingDeadlineGeneration,
},
});
return result;
};
@@ -1,6 +1,6 @@
import { randomInt } from 'node:crypto';
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import {
acquireGameSchemaAdvisoryXactLock,
GamePrisma,
@@ -84,7 +84,9 @@ export interface NpcPossessionSelectionObserver {
interface NpcSelectionTokenRow {
ownerUserId: string;
validUntil: Date;
validUntilTick: bigint | null;
pickMoreFrom: Date;
pickMoreFromTick: bigint | null;
pickResult: unknown;
nonce: number;
}
@@ -105,8 +107,8 @@ const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTi
export const buildNpcSelectionTokenSeed = (
hiddenSeed: string | number,
ownerIdentity: string | number,
acceptedGameTick: number
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick);
createdGameTick: number
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, createdGameTick);
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
const meta = asRecord(worldState.meta);
@@ -159,15 +161,22 @@ const parsePickResult = (value: unknown): Record<string, NpcPossessionCandidate>
};
const toReservation = (
token: Pick<NpcSelectionTokenRow, 'validUntil' | 'pickMoreFrom' | 'pickResult' | 'nonce'>,
now: Date
token: Pick<
NpcSelectionTokenRow,
'validUntil' | 'validUntilTick' | 'pickMoreFrom' | 'pickMoreFromTick' | 'pickResult' | 'nonce'
>,
currentGameTick: number,
ticksPerSecond: number
): NpcPossessionReservation => {
if (token.validUntilTick === null || token.pickMoreFromTick === null) {
return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보의 GAME_TIME 기한이 없습니다.');
}
const pickResult = parsePickResult(token.pickResult);
return {
tokenNonce: token.nonce,
validUntil: token.validUntil.toISOString(),
pickMoreFrom: token.pickMoreFrom.toISOString(),
pickMoreSeconds: Math.max(0, Math.ceil((token.pickMoreFrom.getTime() - now.getTime()) / 1000)),
pickMoreSeconds: Math.max(0, Math.ceil((Number(token.pickMoreFromTick) - currentGameTick) / ticksPerSecond)),
candidates: Object.values(pickResult).sort(
(left, right) =>
left.stats.leadership +
@@ -289,16 +298,18 @@ export const reserveNpcPossessionCandidates = async (options: {
refresh?: boolean;
keepIds?: number[];
now?: Date;
acceptedGameTick: number;
createdGameTick: number;
selectionObserver?: NpcPossessionSelectionObserver;
}): Promise<NpcPossessionReservation> => {
const { db, worldState, userId } = options;
requireNpcPossessionWorld(worldState);
const now = truncateToSeconds(options.now ?? new Date());
if (!Number.isSafeInteger(options.acceptedGameTick)) {
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.');
if (!Number.isSafeInteger(options.createdGameTick)) {
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 생성 tick이 올바르지 않습니다.');
}
await lockNpcPossession(db, userId);
const turnTermMinutes = resolveTurnTermMinutes(worldState);
const ticksPerSecond = GAME_TICKS_PER_TURN / (turnTermMinutes * 60);
if (await db.general.findFirst({ where: { userId }, select: { id: true } })) {
fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다');
@@ -324,14 +335,20 @@ export const reserveNpcPossessionCandidates = async (options: {
if (options.refresh) {
fail('CONFLICT', 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.');
}
return toReservation(inFlightToken, now);
return toReservation(inFlightToken, options.createdGameTick, ticksPerSecond);
}
if (existing && existing.validUntil.getTime() < now.getTime()) {
if (
existing &&
(existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick)
) {
await db.npcSelectionToken.deleteMany({
where: {
ownerUserId: userId,
nonce: existing.nonce,
validUntil: { lt: now },
OR: [
{ validUntilTick: null },
{ validUntilTick: { lt: BigInt(options.createdGameTick) } },
],
},
});
existing = null;
@@ -339,7 +356,7 @@ export const reserveNpcPossessionCandidates = async (options: {
const kept: Record<string, NpcPossessionCandidate> = {};
if (existing && options.refresh) {
if (now.getTime() < existing.pickMoreFrom.getTime()) {
if (existing.pickMoreFromTick === null || options.createdGameTick < Number(existing.pickMoreFromTick)) {
fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다');
}
const oldPick = parsePickResult(existing.pickResult);
@@ -352,16 +369,16 @@ export const reserveNpcPossessionCandidates = async (options: {
}
// Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다.
if (Object.keys(kept).length === Object.keys(oldPick).length) {
return toReservation(existing, now);
return toReservation(existing, options.createdGameTick, ticksPerSecond);
}
} else if (existing) {
return toReservation(existing, now);
return toReservation(existing, options.createdGameTick, ticksPerSecond);
}
const reservedRows = await db.npcSelectionToken.findMany({
where: {
ownerUserId: { not: userId },
validUntil: { gte: now },
validUntilTick: { gte: BigInt(options.createdGameTick) },
},
select: { pickResult: true },
});
@@ -397,16 +414,19 @@ export const reserveNpcPossessionCandidates = async (options: {
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
);
const selectionRng = new LiteHashDRBG(
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick)
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.createdGameTick)
);
const rng = options.selectionObserver?.onRandomDraw
? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw)
: new RandUtil(selectionRng);
const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw);
const turnTermMinutes = resolveTurnTermMinutes(worldState);
const validUntil = new Date(now.getTime() + Math.max(VALID_SECONDS, turnTermMinutes * 40) * 1000);
const validSeconds = Math.max(VALID_SECONDS, turnTermMinutes * 40);
const pickMoreSeconds = Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8));
const validUntilTick = options.createdGameTick + Math.round(validSeconds * ticksPerSecond);
const pickMoreFromTick = options.createdGameTick + Math.round(pickMoreSeconds * ticksPerSecond);
const validUntil = new Date(now.getTime() + validSeconds * 1000);
const refreshedPickMoreFrom = new Date(
now.getTime() + Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8)) * 1000
now.getTime() + pickMoreSeconds * 1000
);
const nonce = randomInt(0, 0x10000000);
@@ -415,7 +435,9 @@ export const reserveNpcPossessionCandidates = async (options: {
where: { ownerUserId: userId, nonce: existing.nonce },
data: {
validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: refreshedPickMoreFrom,
pickMoreFromTick: BigInt(pickMoreFromTick),
pickResult: pickResult as GamePrisma.InputJsonValue,
nonce,
},
@@ -423,7 +445,18 @@ export const reserveNpcPossessionCandidates = async (options: {
if (updated.count === 0) {
fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요');
}
return toReservation({ validUntil, pickMoreFrom: refreshedPickMoreFrom, pickResult, nonce }, now);
return toReservation(
{
validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: refreshedPickMoreFrom,
pickMoreFromTick: BigInt(pickMoreFromTick),
pickResult,
nonce,
},
options.createdGameTick,
ticksPerSecond
);
}
try {
@@ -431,7 +464,9 @@ export const reserveNpcPossessionCandidates = async (options: {
data: {
ownerUserId: userId,
validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: FIRST_PICK_MORE_FROM,
pickMoreFromTick: BigInt(options.createdGameTick),
pickResult: pickResult as GamePrisma.InputJsonValue,
nonce,
},
@@ -442,7 +477,18 @@ export const reserveNpcPossessionCandidates = async (options: {
}
throw error;
}
return toReservation({ validUntil, pickMoreFrom: FIRST_PICK_MORE_FROM, pickResult, nonce }, now);
return toReservation(
{
validUntil,
validUntilTick: BigInt(validUntilTick),
pickMoreFrom: FIRST_PICK_MORE_FROM,
pickMoreFromTick: BigInt(options.createdGameTick),
pickResult,
nonce,
},
options.createdGameTick,
ticksPerSecond
);
};
export const possessNpcGeneral = async (options: {
@@ -455,11 +501,13 @@ export const possessNpcGeneral = async (options: {
ownerLegacyPenalty?: Record<string, unknown>;
generalId: number;
tokenNonce: number;
acceptedAt: Date;
requestedAtWall: Date;
processingGameTick: number;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, generalId, acceptedAt } = options;
// queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다.
const tokenAcceptedAt = truncateToSeconds(acceptedAt);
const { db, world, worldState, userId, generalId, requestedAtWall } = options;
if (!Number.isSafeInteger(options.processingGameTick)) {
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 처리 tick이 올바르지 않습니다.');
}
requireNpcPossessionWorld(worldState);
await lockNpcPossession(db, userId);
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
@@ -475,7 +523,7 @@ export const possessNpcGeneral = async (options: {
where: {
ownerUserId: userId,
nonce: options.tokenNonce,
validUntil: { gte: tokenAcceptedAt },
validUntilTick: { gte: BigInt(options.processingGameTick) },
},
})) as NpcSelectionTokenRow | null;
if (!token) {
@@ -501,7 +549,7 @@ export const possessNpcGeneral = async (options: {
return fail('NOT_FOUND', '장수 등록에 실패했습니다.');
}
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, acceptedAt);
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, requestedAtWall);
world.updateGeneral(generalId, {
userId,
npcState: 1,
@@ -522,7 +570,7 @@ export const possessNpcGeneral = async (options: {
where: { generalId },
update: {
userId,
lastRefresh: acceptedAt,
lastRefresh: requestedAtWall,
refresh: 0,
refreshTotal: 0,
refreshScore: 0,
@@ -531,7 +579,7 @@ export const possessNpcGeneral = async (options: {
create: {
generalId,
userId,
lastRefresh: acceptedAt,
lastRefresh: requestedAtWall,
},
});
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId } });
+28 -11
View File
@@ -1,4 +1,9 @@
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra';
import {
createGamePostgresConnector,
GamePrisma,
type InputJsonValue,
type TurnEngineDatabaseClient,
} from '@sammo-ts/infra';
import { isRecord } from '@sammo-ts/common';
import { randomUUID } from 'node:crypto';
@@ -75,6 +80,7 @@ const buildTurnListFromRows = (
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & {
$queryRaw?<T>(query: GamePrisma.Sql): Promise<T>;
generalTurnRevision?: Pick<
NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>,
'findUnique' | 'createMany' | 'updateMany'
@@ -313,8 +319,17 @@ export class InMemoryReservedTurnStore {
}
}
private getLeaseExpiresAt(): Date {
return new Date(Date.now() + this.leaseDurationMs);
private getLeaseExpiresAt(nowWall: Date): Date {
return new Date(nowWall.getTime() + this.leaseDurationMs);
}
private async readDatabaseWallTime(prisma: ReservedTurnDatabaseClient = this.prisma): Promise<Date> {
if (!prisma.$queryRaw) return new Date();
const rows = await prisma.$queryRaw<Array<{ nowWall: Date }>>(GamePrisma.sql`
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "nowWall"
`);
if (!rows[0]) throw new Error('PostgreSQL did not return its authoritative wall clock.');
return rows[0].nowWall;
}
private async acquireGeneralLease(generalId: number): Promise<boolean> {
@@ -322,7 +337,7 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) {
return false;
}
const now = new Date();
const now = await this.readDatabaseWallTime();
const previous = (await revisionStore.findUnique({ where: { generalId } })) as {
leaseOwner: string | null;
leaseExpiresAt: Date | null;
@@ -331,7 +346,7 @@ export class InMemoryReservedTurnStore {
previous?.leaseOwner === this.leaseOwner &&
previous.leaseExpiresAt !== null &&
previous.leaseExpiresAt.getTime() > now.getTime();
const leaseExpiresAt = this.getLeaseExpiresAt();
const leaseExpiresAt = this.getLeaseExpiresAt(now);
let claimed = await revisionStore.updateMany({
where: {
generalId,
@@ -372,7 +387,7 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) {
return false;
}
const now = new Date();
const now = await this.readDatabaseWallTime();
const previous = (await revisionStore.findUnique({
where: { nationId_officerLevel: { nationId, officerLevel } },
})) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null;
@@ -380,7 +395,7 @@ export class InMemoryReservedTurnStore {
previous?.leaseOwner === this.leaseOwner &&
previous.leaseExpiresAt !== null &&
previous.leaseExpiresAt.getTime() > now.getTime();
const leaseExpiresAt = this.getLeaseExpiresAt();
const leaseExpiresAt = this.getLeaseExpiresAt(now);
let claimed = await revisionStore.updateMany({
where: {
nationId,
@@ -651,12 +666,13 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) {
return false;
}
const leaseExpiresAt = this.getLeaseExpiresAt();
const now = await this.readDatabaseWallTime(prisma);
const leaseExpiresAt = this.getLeaseExpiresAt(now);
const where = this.leasedGeneralIds.has(generalId)
? { generalId, leaseOwner: this.leaseOwner }
: {
generalId,
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
};
let claimed = await revisionStore.updateMany({
where,
@@ -721,13 +737,14 @@ export class InMemoryReservedTurnStore {
if (!revisionStore) {
return false;
}
const leaseExpiresAt = this.getLeaseExpiresAt();
const now = await this.readDatabaseWallTime(prisma);
const leaseExpiresAt = this.getLeaseExpiresAt(now);
const where = this.leasedNationKeys.has(key)
? { nationId, officerLevel, leaseOwner: this.leaseOwner }
: {
nationId,
officerLevel,
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
};
let claimed = await revisionStore.updateMany({
where,
@@ -1,5 +1,6 @@
import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import {
buildGameEventChannel,
@@ -89,8 +90,8 @@ const shiftTournamentClock = async (
};
const lockKey = `${stateKey}:mutation-lock`;
const token = randomUUID();
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
const deadline = performance.now() + 2_000;
while (performance.now() < deadline) {
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) {
try {
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import {
buildGameEventChannel,
@@ -158,8 +159,8 @@ const reprojectTournamentClock = async (
};
const lockKey = `${stateKey}:mutation-lock`;
const token = randomUUID();
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
const deadline = performance.now() + 2_000;
while (performance.now() < deadline) {
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) {
try {
+52 -62
View File
@@ -268,13 +268,10 @@ const toReservationDto = (
world: InMemoryTurnWorld
): Promise<SelectPoolReservationDto> => {
const first = rows[0];
if (!first || (first.reservedUntilTick === null && first.reservedUntil === null)) {
if (!first || first.reservedUntilTick === null) {
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
}
const expiresAt =
first.reservedUntilTick === null
? first.reservedUntil!
: world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
const expiresAt = world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
const poolName = resolvePoolName(worldState);
if (!poolName || !SUPPORTED_POOLS.has(poolName)) {
throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
@@ -355,6 +352,23 @@ const readNextChangeAt = (generalMeta: unknown): Date | null => {
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const readNextChangeTick = (generalMeta: unknown): number | null => {
const raw = asRecord(generalMeta).next_change_tick;
const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN;
return Number.isSafeInteger(parsed) ? parsed : null;
};
const assertReselectionCooldown = (generalMeta: unknown, processingGameTick: number): void => {
const projection = readNextChangeAt(generalMeta);
const deadline = readNextChangeTick(generalMeta);
if (projection && deadline === null) {
fail('INTERNAL_SERVER_ERROR', '장수 재선택 cooldown의 GAME_TIME authority가 없습니다.');
}
if (deadline !== null && deadline > processingGameTick) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
};
const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
const meta = asRecord(worldState.meta);
const value = meta.hiddenSeed ?? meta.seed;
@@ -371,18 +385,8 @@ const toSafeReservationTick = (value: bigint | number, uniqueName: string): numb
return tick;
};
const resolveAcceptedGameTick = (world: InMemoryTurnWorld, now: Date): number => {
const tick = world.dateToGameTick(now);
if (!Number.isSafeInteger(tick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
}
return tick;
};
const isReservationActive = (row: SelectPoolRow, now: Date, nowTick: number): boolean =>
row.reservedUntilTick !== null
? toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick
: row.reservedUntil !== null && row.reservedUntil.getTime() >= now.getTime();
const isReservationActive = (row: SelectPoolRow, nowTick: number): boolean =>
row.reservedUntilTick !== null && toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick;
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
@@ -392,7 +396,6 @@ const requireSelectionToken = async (
db: DatabaseClient,
userId: string,
uniqueName: string,
now: Date,
nowTick: number
): Promise<SelectPoolRow> => {
const token = await db.selectPoolEntry.findFirst({
@@ -400,10 +403,7 @@ const requireSelectionToken = async (
ownerUserId: userId,
uniqueName,
generalId: null,
OR: [
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
reservedUntilTick: { gte: BigInt(nowTick) },
},
});
if (!token) {
@@ -438,18 +438,13 @@ export const reserveSelectionPool = async (options: {
worldState: WorldStateRow;
userId: string;
now?: Date;
acceptedGameTick?: number;
processingGameTick?: number;
processingGameTick: number;
seedOwnerIdentity?: string | number;
}): Promise<SelectPoolReservationDto> => {
const { db, world, worldState, userId } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
const acceptedGameTick = options.acceptedGameTick ?? resolveAcceptedGameTick(world, now);
const processingGameTick = options.processingGameTick ?? acceptedGameTick;
if (!Number.isSafeInteger(acceptedGameTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
}
const processingGameTick = options.processingGameTick;
if (!Number.isSafeInteger(processingGameTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
}
@@ -459,14 +454,11 @@ export const reserveSelectionPool = async (options: {
where: { userId },
select: { id: true, meta: true },
});
const nextChangeAt = general ? readNextChangeAt(general.meta) : null;
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
if (general) assertReselectionCooldown(general.meta, processingGameTick);
let currentRows = await synchronizeSelectionPoolWorld(db, world);
const existing = currentRows.filter(
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, now, processingGameTick)
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, processingGameTick)
);
if (existing.length > 0) {
return toReservationDto(existing, Boolean(general), worldState, world);
@@ -477,7 +469,7 @@ export const reserveSelectionPool = async (options: {
generalId: null,
OR: [
{ reservedUntilTick: { lt: BigInt(processingGameTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: now } },
{ reservedUntilTick: null, reservedUntil: { not: null } },
],
},
data: {
@@ -504,7 +496,7 @@ export const reserveSelectionPool = async (options: {
const rng = new RandUtil(
new LiteHashDRBG(
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, acceptedGameTick)
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, processingGameTick)
)
);
const poolName = resolvePoolName(worldState)!;
@@ -578,7 +570,6 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor
const clearUnusedReservations = async (
db: DatabaseClient,
userId: string,
now: Date,
nowTick: number
): Promise<void> => {
await db.selectPoolEntry.updateMany({
@@ -587,7 +578,7 @@ const clearUnusedReservations = async (
OR: [
{ ownerUserId: userId },
{ reservedUntilTick: { lt: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { lt: now } },
{ reservedUntilTick: null, reservedUntil: { not: null } },
],
},
data: {
@@ -716,6 +707,7 @@ export const createGeneralFromSelectionPool = async (options: {
now?: Date;
turnScheduleAt?: Date;
operationalAcceptedAt: Date;
processingGameTick: number;
seedOwnerIdentity?: string | number;
ownerPicture?: string;
ownerImageServer?: number;
@@ -724,7 +716,10 @@ export const createGeneralFromSelectionPool = async (options: {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
const nowTick = resolveAcceptedGameTick(world, now);
const nowTick = options.processingGameTick;
if (!Number.isSafeInteger(nowTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 생성 처리 tick이 안전한 정수 범위를 벗어났습니다.');
}
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
await synchronizeSelectionPoolWorld(db, world);
@@ -735,7 +730,7 @@ export const createGeneralFromSelectionPool = async (options: {
) {
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.');
}
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
const info = parseCandidate(token);
const poolName = resolvePoolName(worldState)!;
const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL;
@@ -777,9 +772,8 @@ export const createGeneralFromSelectionPool = async (options: {
const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
const age = 20;
const specialityAges = resolveSpecialityAges(worldState, age);
const nextChangeAt = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const nextChangeTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
const nextChangeAt = world.gameTickToDate(nextChangeTick);
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
@@ -813,6 +807,7 @@ export const createGeneralFromSelectionPool = async (options: {
dex5: isCentennial ? 0 : info.dex[4],
next_change: nextChangeAt.toISOString(),
nextChangeAt: nextChangeAt.toISOString(),
next_change_tick: nextChangeTick,
prestart_delete_after: prestartDeleteAfter.toISOString(),
...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}),
npc_org: 0,
@@ -905,10 +900,7 @@ export const createGeneralFromSelectionPool = async (options: {
id: token.id,
ownerUserId: userId,
generalId: null,
OR: [
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
reservedUntilTick: { gte: BigInt(nowTick) },
},
data: {
generalId,
@@ -925,7 +917,7 @@ export const createGeneralFromSelectionPool = async (options: {
update: { userId, lastRefresh: options.operationalAcceptedAt },
create: { generalId, userId, lastRefresh: options.operationalAcceptedAt },
});
await clearUnusedReservations(db, userId, now, nowTick);
await clearUnusedReservations(db, userId, nowTick);
await synchronizeSelectionPoolWorld(db, world);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
@@ -949,11 +941,15 @@ export const reselectGeneralFromSelectionPool = async (options: {
ownerDisplayName: string;
uniqueName: string;
now?: Date;
processingGameTick: number;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
const nowTick = resolveAcceptedGameTick(world, now);
const nowTick = options.processingGameTick;
if (!Number.isSafeInteger(nowTick)) {
fail('INTERNAL_SERVER_ERROR', '장수 재선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
}
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
await synchronizeSelectionPoolWorld(db, world);
@@ -968,11 +964,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
if (persistedGeneral.id !== general.id) {
fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.');
}
const nextChangeAt = readNextChangeAt(general.meta);
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
assertReselectionCooldown(general.meta, nowTick);
const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
const info = parseCandidate(token);
const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL;
@@ -982,10 +975,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
id: token.id,
ownerUserId: userId,
generalId: null,
OR: [
{ reservedUntilTick: { gte: BigInt(nowTick) } },
{ reservedUntilTick: null, reservedUntil: { gte: now } },
],
reservedUntilTick: { gte: BigInt(nowTick) },
},
data: {
generalId: provisionalGeneralId,
@@ -1014,9 +1004,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
}
const cooldown = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const cooldownTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
const cooldown = world.gameTickToDate(cooldownTick);
const centennialBaseGeneral = isCentennial
? {
...general,
@@ -1046,6 +1035,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
: {}),
next_change: cooldown.toISOString(),
nextChangeAt: cooldown.toISOString(),
next_change_tick: cooldownTick,
...buildScenarioGeneralPoolClaimMeta(
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
now
@@ -1074,7 +1064,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
if (!updated) {
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
}
await clearUnusedReservations(db, userId, now, nowTick);
await clearUnusedReservations(db, userId, nowTick);
await synchronizeSelectionPoolWorld(db, world);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
@@ -1,5 +1,9 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
import {
acquireGameSchemaAdvisoryXactLock,
enqueuePrivateMessageWebPush,
persistMessageEnvelope,
} from '@sammo-ts/infra';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
import {
@@ -119,22 +123,18 @@ interface HighestUnificationBidRow {
meta: unknown;
}
const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: MessageRecordDraft): Promise<number> => {
const rows = await transaction.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${draft.validUntil},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
const insertMessage = async (
transaction: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
draft: MessageRecordDraft
): Promise<number> => {
const clock = world.getGameClockState();
const id = await persistMessageEnvelope(transaction, draft, {
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
clockRevision: BigInt(clock.revision),
deadlineGeneration: BigInt(clock.deadlineGeneration),
expiresGameTick: null,
});
await enqueuePrivateMessageWebPush(transaction, draft, id);
return id;
};
@@ -252,7 +252,7 @@ const cancelPendingUniqueAuctions = async (
await sendMessage(
{
insertMessage: async (draft) => {
const messageId = await insertMessage(transaction, draft);
const messageId = await insertMessage(transaction, world, draft);
messageMailboxes.push(draft.mailbox);
return messageId;
},
+26 -46
View File
@@ -278,18 +278,12 @@ const resolveSelectionCommandAcceptedAt = async (
world: InMemoryTurnWorld,
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }>
): Promise<Date> => {
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
await resolveCommandAcceptedAt(db, command);
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) {
return world.gameTickToDate(processingGameTick);
}
if (command.acceptedGameTick !== undefined) {
return world.gameTickToDate(command.acceptedGameTick);
}
if (command.acceptedGameAt !== undefined) {
return new Date(command.acceptedGameAt);
}
return world.getGameNow(operationalAcceptedAt);
throw new Error(`${command.type} requires an authoritative daemon processing game tick.`);
};
const resolveOperationalAcceptedAt = async (
@@ -417,12 +411,9 @@ async function handleNpcPossessGeneral(
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const processingGameTick = Reflect.get(command, 'processingGameTick');
const acceptedAt =
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)
? ctx.world.gameTickToDate(processingGameTick)
: command.acceptedGameAt
? new Date(command.acceptedGameAt)
: ctx.world.getGameNow(operationalAcceptedAt);
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('npcPossessGeneral requires an authoritative daemon processing game tick.');
}
try {
return {
type: 'npcPossessGeneral',
@@ -436,7 +427,8 @@ async function handleNpcPossessGeneral(
...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}),
generalId: command.generalId,
tokenNonce: command.tokenNonce,
acceptedAt,
requestedAtWall: operationalAcceptedAt,
processingGameTick,
})),
};
} catch (error) {
@@ -465,15 +457,11 @@ async function handleSelectPoolCreate(
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const processingGameTick = Reflect.get(command, 'processingGameTick');
const acceptedAt =
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)
? ctx.world.gameTickToDate(processingGameTick)
: command.acceptedGameTick !== undefined
? ctx.world.gameTickToDate(command.acceptedGameTick)
: command.acceptedGameAt !== undefined
? new Date(command.acceptedGameAt)
: ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = acceptedAt;
try {
return {
type: 'selectPoolCreate',
@@ -492,6 +480,7 @@ async function handleSelectPoolCreate(
now: acceptedAt,
turnScheduleAt,
operationalAcceptedAt,
processingGameTick,
})),
};
} catch (error) {
@@ -530,10 +519,7 @@ async function handleSelectPoolReserve(
userId: command.userId,
seedOwnerIdentity: command.seedOwnerIdentity,
now: acceptedAt,
...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }),
...(typeof Reflect.get(command, 'processingGameTick') === 'number'
? { processingGameTick: Reflect.get(command, 'processingGameTick') as number }
: {}),
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
}),
};
} catch (error) {
@@ -572,6 +558,7 @@ async function handleSelectPoolReselect(
ownerDisplayName: command.ownerDisplayName,
uniqueName: command.uniqueName,
now: acceptedAt,
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
})),
};
} catch (error) {
@@ -2745,17 +2732,16 @@ type VotePollValidationRow = {
export const hasVotePollDeadlinePassed = (
poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>,
acceptedGameAt: Date,
acceptedGameTick: number
currentGameTick: number
): boolean => {
const endTick =
poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick);
return (
poll.closedAt !== null ||
(endTick !== null
? endTick < BigInt(acceptedGameTick)
: Boolean(poll.endAt && poll.endAt.getTime() < acceptedGameAt.getTime()))
);
if (poll.closedAt !== null) return true;
// No projection and no tick means an intentionally unbounded poll. A
// projection without its authoritative tick is a broken GAME deadline and
// therefore fails closed.
if (endTick === null) return poll.endAt !== null;
return endTick < BigInt(currentGameTick);
};
const parseVoteOptionCount = (value: unknown): number => {
@@ -2790,17 +2776,11 @@ const validateVoteSelectionInTransaction = async (
const poll = rows[0];
if (!poll) return '설문조사가 없습니다.';
const processingNow = ctx.world.getGameNow(new Date());
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
const acceptedGameTick =
typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick)
? convertedProcessingTick
: (command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow));
const acceptedGameAt =
command.acceptedGameTick === undefined && convertedProcessingTick === undefined
? processingNow
: ctx.world.gameTickToDate(acceptedGameTick);
if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) {
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
throw new Error('voteReward requires an authoritative daemon processing game tick.');
}
if (hasVotePollDeadlinePassed(poll, convertedProcessingTick)) {
return '설문조사가 종료되었습니다.';
}
@@ -87,6 +87,7 @@ const destination = {
color: '#ffffff',
icon: '',
};
const requestId = 'actionable-message-request';
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
id: 29,
@@ -94,6 +95,10 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
type: 'private',
time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
actionType: action,
actionStatus: 'PENDING',
createdGameTick: 0n,
expiresGameTick: null,
message: {
src: source,
dest: destination,
@@ -106,10 +111,25 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
const buildDb = (rows: unknown[][]) => {
const queryRaw = vi.fn(async () => rows.shift() ?? []);
const updateMany = vi.fn(async () => ({ count: 1 }));
const actionUpdateMany = vi.fn(async () => ({ count: 1 }));
return {
db: { $queryRaw: queryRaw, message: { updateMany } } as unknown as GamePrisma.TransactionClient,
db: {
$queryRaw: queryRaw,
inputEvent: {
findUnique: vi.fn(async () => ({
actorUserId: actor.userId,
target: 'ENGINE',
eventType: 'messageRespond',
createdAt: new Date('2026-09-03T00:00:00.000Z'),
processingGameTick: 0n,
})),
},
message: { updateMany },
messageAction: { updateMany: actionUpdateMany },
} as unknown as GamePrisma.TransactionClient,
queryRaw,
updateMany,
actionUpdateMany,
};
};
@@ -118,6 +138,22 @@ const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({
});
describe('actionable message response', () => {
it('rejects a response without the authoritative durable command boundary', async () => {
const world = buildWorld();
const { db } = buildDb([[buildRow('scout')]]);
await expect(
respondToActionableMessage({
db,
world,
executor: buildExecutor(),
userId: actor.userId!,
generalId: actor.id,
messageId: 29,
response: true,
})
).rejects.toThrow('durable ENGINE input event requestId');
});
it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => {
const world = buildWorld();
const row = buildRow('scout');
@@ -128,6 +164,7 @@ describe('actionable message response', () => {
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -162,6 +199,7 @@ describe('actionable message response', () => {
db,
world,
executor: buildExecutor(false),
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -173,14 +211,8 @@ describe('actionable message response', () => {
expect(world.peekDirtyState().messages).toHaveLength(0);
});
it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => {
for (const row of [
buildRow('scout', { option: { action: 'scout', used: 1 } }),
{
...buildRow('scout'),
validUntil: new Date('0199-12-31T23:59:59.000Z'),
},
]) {
it('treats a legacy truthy used value as an invalid scout letter', async () => {
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
const world = buildWorld();
const { db, updateMany } = buildDb([[row]]);
const executor = buildExecutor();
@@ -190,6 +222,7 @@ describe('actionable message response', () => {
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -201,6 +234,24 @@ describe('actionable message response', () => {
}
});
it('treats an expired GAME_TIME action row as absent', async () => {
const world = buildWorld();
const { db } = buildDb([[]]);
await expect(
respondToActionableMessage({
db,
world,
executor: buildExecutor(),
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: 29,
response: true,
})
).resolves.toEqual({ ok: false, reason: '존재하지 않는 메시지입니다.' });
});
it("keeps PHP's special string-zero used value false", async () => {
const world = buildWorld();
const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
@@ -212,6 +263,7 @@ describe('actionable message response', () => {
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -233,6 +285,7 @@ describe('actionable message response', () => {
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -252,6 +305,7 @@ describe('actionable message response', () => {
db,
world,
executor: buildExecutor(),
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -271,6 +325,7 @@ describe('actionable message response', () => {
db,
world,
executor: buildExecutor(),
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: row.id,
@@ -107,6 +107,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'],
});
const amount = finishImmediately ? 500 : 200;
const requestedAtWall = new Date('2026-08-23T00:00:00.000Z');
const result = await auctionBidder.bid(
{
type: 'auctionBid',
@@ -114,8 +115,9 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
auctionId: 31,
generalId: general.id,
amount,
acceptedGameTick: 100,
},
processingGameTick: 100,
requestedAtWall,
} as any,
commandDb as any
);
await auctionBidder.close();
@@ -125,7 +127,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
);
const insert = statements.find((query) => query.strings.join(' ').includes('INSERT INTO auction_bid'));
const update = statements.find((query) => query.strings.join(' ').includes('UPDATE auction'));
return { acceptedAt, processingAt, result, insert, update };
return { acceptedAt, processingAt, requestedAtWall, result, insert, update };
};
describe('resource auction Ref compatibility', () => {
@@ -135,21 +137,19 @@ describe('resource auction Ref compatibility', () => {
expect(hasAuctionClosePassed(auction, closeAt, 72_000_000)).toBe(false);
expect(hasAuctionClosePassed(auction, new Date(closeAt.getTime() + 1), 72_000_001)).toBe(true);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(false);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(true);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, new Date(closeAt.getTime() + 1), null)).toBe(true);
});
it('uses the durable API acceptance tick when queue processing crosses the close boundary', () => {
it('uses only the authoritative daemon processing tick at the close boundary', () => {
const closeAt = new Date('0190-02-01T00:00:00.000Z');
const auction = { closeAt, closeTick: 72_000_000n };
const world = {
dateToGameTick: () => 72_000_001,
gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)),
};
const processingNow = new Date(closeAt.getTime() + 1);
expect(hasAuctionBidClosePassed(auction, world, processingNow, 72_000_000)).toBe(false);
expect(hasAuctionBidClosePassed(auction, world, processingNow)).toBe(true);
expect(hasAuctionBidClosePassed(auction, world, 72_000_000)).toBe(false);
expect(hasAuctionBidClosePassed(auction, world, 72_000_001)).toBe(true);
expect(
normalizeTurnDaemonCommand({
requestId: 'auction-bid-accepted-tick',
@@ -161,23 +161,26 @@ describe('resource auction Ref compatibility', () => {
generalId: 7,
amount: 500,
acceptedGameTick: 72_000_000,
},
} as any,
})
).toMatchObject({ acceptedGameTick: 72_000_000 });
).not.toHaveProperty('acceptedGameTick');
});
it('uses the accepted logical time for delayed extension and persisted bid timestamps', async () => {
const { acceptedAt, processingAt, result, insert, update } = await runDelayedResourceBid(false);
const { acceptedAt, processingAt, requestedAtWall, result, insert, update } =
await runDelayedResourceBid(false);
expect(result).toMatchObject({ type: 'auctionBid', ok: true });
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
acceptedAt.getTime() + 100_000
);
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([acceptedAt]);
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([
acceptedAt,
requestedAtWall,
]);
expect(update?.values.filter((value): value is Date => value instanceof Date)).toEqual([
new Date(acceptedAt.getTime() + 100_000),
acceptedAt,
acceptedAt,
]);
expect(update?.values).not.toContain(processingAt);
});
@@ -27,6 +27,12 @@ import {
import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js';
import type { TurnGeneral } from '../src/turn/types.js';
const withDaemonBoundary = <T extends object>(command: T, processingGameTick = 72_000_000): T =>
Object.assign(command, {
processingGameTick,
requestedAtWall: new Date('2026-09-03T00:00:00.000Z'),
});
describe('unique auction inheritance log compatibility', () => {
it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => {
const userId = '4c2f2f6d-8a37-4f22-a4f9-1a6f5e4c22ec';
@@ -113,19 +119,20 @@ describe('unique auction inheritance log compatibility', () => {
}),
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
dateToGameTick: (date: Date) => Math.floor(date.getTime() / 1_000),
gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'),
updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
pushLog: () => {},
};
const result = await openAuction(
{
withDaemonBoundary({
type: 'auctionOpen',
userId: 'user-7',
auctionType: 'UNIQUE_ITEM',
generalId: general.id,
amount: 6_000,
itemKey: 'che_무기_12_칠성검',
},
}),
world as unknown as Parameters<typeof openAuction>[1],
db as unknown as NonNullable<Parameters<typeof openAuction>[2]>
);
@@ -192,6 +199,7 @@ describe('unique auction inheritance log compatibility', () => {
const world = {
getGameNow: () => closeAt,
dateToGameTick: () => 72_000_000,
gameTickToDate: () => closeAt,
pushLog: vi.fn(),
};
const finalizer = await createAuctionFinalizer({
@@ -201,12 +209,12 @@ describe('unique auction inheritance log compatibility', () => {
await expect(
finalizer.finalize(
{
withDaemonBoundary({
type: 'auctionFinalize',
auctionId: 31,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
}),
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
)
).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 });
@@ -241,6 +249,7 @@ describe('unique auction inheritance log compatibility', () => {
const world = {
getGameNow: () => closeAt,
dateToGameTick: () => nowTick,
gameTickToDate: () => closeAt,
};
const finalizer = await createAuctionFinalizer({
databaseUrl: 'postgresql://unused',
@@ -249,11 +258,23 @@ describe('unique auction inheritance log compatibility', () => {
const db = commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>;
await expect(
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }, db)
finalizer.finalize(
withDaemonBoundary(
{ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 },
71_999_999
),
db
)
).resolves.toMatchObject({ ok: false, reason: '경매 마감 시각이 아직 지나지 않았습니다.' });
nowTick = 72_000_000;
await expect(
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 }, db)
finalizer.finalize(
withDaemonBoundary(
{ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 },
72_000_000
),
db
)
).resolves.toMatchObject({ ok: false, reason: '경매 마감 세대가 변경되었습니다.' });
expect(executeRaw).not.toHaveBeenCalled();
@@ -276,12 +297,16 @@ describe('unique auction inheritance log compatibility', () => {
detail: { amount: 100 },
status: 'OPEN',
closeAt,
closeTick: null,
closeTick: 72_000_000n,
},
];
});
const commandDb = { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 0) };
const world = { getGameNow: () => closeAt, dateToGameTick: () => 72_000_000 };
const world = {
getGameNow: () => closeAt,
dateToGameTick: () => 72_000_000,
gameTickToDate: () => closeAt,
};
const finalizer = await createAuctionFinalizer({
databaseUrl: 'postgresql://unused',
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
@@ -289,7 +314,12 @@ describe('unique auction inheritance log compatibility', () => {
await expect(
finalizer.finalize(
{ type: 'auctionFinalize', auctionId: 31, expectedCloseAt: closeAt.toISOString() },
withDaemonBoundary({
type: 'auctionFinalize',
auctionId: 31,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}),
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
)
).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31');
@@ -317,6 +347,7 @@ describe('unique auction inheritance log compatibility', () => {
const queueMessage = vi.fn();
const world = {
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'),
getGeneralById: (id: number) => (id === bidder.id ? bidder : id === host.id ? host : null),
getNationById: () => ({ name: '촉', color: '#ff0000' }),
updateGeneral,
@@ -350,7 +381,7 @@ describe('unique auction inheritance log compatibility', () => {
await expect(
finalizer.finalize(
{ type: 'auctionFinalize', auctionId: 31 },
withDaemonBoundary({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }),
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
)
).resolves.toMatchObject({
@@ -79,6 +79,7 @@ describeIntegration('durable clock reconciliation', () => {
revision: 1,
});
const generalTicks = [initialTick + 1_234, initialTick + 36_000_123];
const reselectionTick = initialTick + 54_000_456;
const auctionCloseTick = initialTick + 72_000_777;
const messageOccurrenceTick = initialTick - 500;
const messageExpiryTick = initialTick + 90_000_999;
@@ -116,6 +117,14 @@ describeIntegration('durable clock reconciliation', () => {
turnTime: clock.tickToDate(turnTick),
recentWarTick: BigInt(initialTick - 100 - index),
recentWarTime: clock.tickToDate(initialTick - 100 - index),
meta:
index === 0
? {
next_change_tick: reselectionTick,
next_change: clock.tickToDate(reselectionTick).toISOString(),
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
}
: {},
})),
});
await db.auction.create({
@@ -138,7 +147,20 @@ describeIntegration('durable clock reconciliation', () => {
timeTick: BigInt(messageOccurrenceTick),
validUntil: clock.tickToDate(messageExpiryTick),
validUntilTick: BigInt(messageExpiryTick),
createdAtWall: new Date('2026-01-01T12:34:56.789Z'),
deleteUntilWall: new Date('2026-01-01T12:39:56.789Z'),
occurredGameTick: BigInt(messageOccurrenceTick),
message: {},
action: {
create: {
actionType: 'scout',
status: 'PENDING',
createdGameTick: BigInt(messageOccurrenceTick),
expiresGameTick: BigInt(messageExpiryTick),
clockRevision: 1n,
deadlineGeneration: 7n,
},
},
},
});
await db.votePoll.create({
@@ -201,17 +223,19 @@ describeIntegration('durable clock reconciliation', () => {
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.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(),
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
db.clockProjectionOutbox.findMany(),
]);
]);
const alignedTick = BigInt(reconciled.alignedTick);
expect(afterWorld).toMatchObject({
clockPhase: 'RECONCILING',
@@ -223,8 +247,19 @@ describeIntegration('durable clock reconciliation', () => {
expect(generals.map((general) => general.turnTick! - alignedTick)).toEqual(
generalTicks.map((tick) => BigInt(tick - initialTick))
);
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
);
expect(auction.closeTick! - alignedTick).toBe(BigInt(auctionCloseTick - initialTick));
expect(message.validUntilTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick));
expect(messageAction.expiresGameTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick));
expect(messageAction.createdGameTick).toBe(BigInt(messageOccurrenceTick));
expect(messageAction.clockRevision).toBe(2n);
expect(messageAction.deadlineGeneration).toBe(8n);
expect(message.createdAtWall).toEqual(new Date('2026-01-01T12:34:56.789Z'));
expect(message.deleteUntilWall).toEqual(new Date('2026-01-01T12:39:56.789Z'));
expect(vote.endTick! - alignedTick).toBe(BigInt(voteEndTick - initialTick));
expect(pool.reservedUntilTick! - alignedTick).toBe(BigInt(poolTick - initialTick));
expect(token.validUntilTick! - alignedTick).toBe(BigInt(npcValidTick - initialTick));
@@ -302,6 +337,21 @@ describeIntegration('durable clock reconciliation', () => {
await db.general.create({
data: { id: 1, name: 'day-general', turnTick: BigInt(turnTick), turnTime: clock.tickToDate(turnTick) },
});
const wallMessageCreatedAt = new Date('2026-01-15T12:00:00.000Z');
const wallMessageDeleteUntil = new Date('2026-01-15T12:05:00.000Z');
const wallMessage = await db.message.create({
data: {
mailbox: 0,
type: 'public',
src: 1,
dest: 0,
time: wallMessageCreatedAt,
validUntil: new Date('9999-12-31T00:00:00.000Z'),
createdAtWall: wallMessageCreatedAt,
deleteUntilWall: wallMessageDeleteUntil,
message: { src: {}, dest: {}, text: 'wall clock survives 24h suspension', option: {} },
},
});
await db.turnDaemonLease.create({
data: {
profile: 'clock-day-test',
@@ -346,6 +396,10 @@ describeIntegration('durable clock reconciliation', () => {
});
const shifted = await db.general.findUniqueOrThrow({ where: { id: 1 } });
expect(shifted.turnTick! - BigInt(reconciled.alignedTick)).toBe(BigInt(turnTick - initialTick));
await expect(db.message.findUniqueOrThrow({ where: { id: wallMessage.id } })).resolves.toMatchObject({
createdAtWall: wallMessageCreatedAt,
deleteUntilWall: wallMessageDeleteUntil,
});
await redis.client.set('sammo:clock-day-test:clock:active-revision', '3');
const redisThenCrash = {
@@ -25,7 +25,26 @@ integration('database command queue', () => {
});
await db.message.deleteMany({ where: { mailbox: 991_199 } });
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 () => {
await cleanupFixtures();
@@ -63,7 +85,16 @@ integration('database command queue', () => {
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
const commands = firstCommands.concat(secondCommands);
expect(commands).toEqual([{ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }]);
expect(commands).toEqual([
{
type: 'vacation',
requestId,
userId: 'user-7',
generalId: 7,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
@@ -118,7 +149,16 @@ integration('database command queue', () => {
await queue.initialize();
const commands = await queue.drain();
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, userId: 'user-8', generalId: 8 }]);
expect(commands).toEqual([
{
type: 'vacation',
requestId: expiredId,
userId: 'user-8',
generalId: 8,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
status: 'PROCESSING',
lockedBy: 'active-worker',
@@ -146,7 +186,14 @@ integration('database command queue', () => {
const stale = new DatabaseTurnDaemonCommandQueue(db);
for (const attempt of [1, 2, 3]) {
await expect(owner.drain()).resolves.toEqual([
{ type: 'vacation', requestId, userId: 'user-10', generalId: 10 },
{
type: 'vacation',
requestId,
userId: 'user-10',
generalId: 10,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
await stale.publishCommandError(requestId, new Error('stale worker failure'));
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
@@ -220,7 +267,13 @@ integration('database command queue', () => {
const owner = new DatabaseTurnDaemonCommandQueue(db);
const claimed = await owner.drain();
expect(claimed).toEqual([command]);
expect(claimed).toEqual([
{
...command,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
expect(result).toMatchObject({
type: 'commandRejected',
@@ -301,7 +354,14 @@ integration('database command queue', () => {
});
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({
status: 'PENDING',
processingClockRevision: null,
@@ -309,7 +369,14 @@ integration('database command queue', () => {
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } });
expect(await queue.drain()).toEqual([
{ type: 'vacation', requestId: gameplayId, userId: 'user-7', generalId: 7 },
{
type: 'vacation',
requestId: gameplayId,
userId: 'user-7',
generalId: 7,
processingGameTick: 100,
requestedAtWall: expect.any(Date),
},
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
status: 'PROCESSING',
@@ -347,6 +414,7 @@ integration('database command queue', () => {
userId: 'user-8',
generalId: 8,
processingGameTick: 123,
requestedAtWall: expect.any(Date),
},
]);
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 () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld
@@ -389,6 +554,37 @@ integration('database command queue', () => {
message: { option: { action: 'raiseInvader', used: false } },
},
});
await db.messageAction.create({
data: {
messageId: message.id,
actionType: 'raiseInvader',
status: 'PENDING',
createdGameTick: 900n,
clockRevision: 31n,
deadlineGeneration: 7n,
},
});
const scoutMessage = await db.message.create({
data: {
mailbox: 991_199,
type: 'private',
src: 7,
dest: 991_199,
time: new Date(),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
message: { option: { action: 'scout', used: false } },
},
});
await db.messageAction.create({
data: {
messageId: scoutMessage.id,
actionType: 'scout',
status: 'PENDING',
createdGameTick: 900n,
clockRevision: 31n,
deadlineGeneration: 7n,
},
});
await db.clockSuspension.create({
data: {
id: 'integration-unification-wait',
@@ -404,6 +600,7 @@ integration('database command queue', () => {
},
});
const messageRequestId = 'integration:engine:unification-message';
const scoutRequestId = 'integration:engine:suspended-scout-response';
const gameplayRequestId = 'integration:engine:unification-gameplay';
await db.inputEvent.createMany({
data: [
@@ -424,6 +621,23 @@ integration('database command queue', () => {
response: true,
},
},
{
requestId: scoutRequestId,
target: 'ENGINE',
eventType: 'messageRespond',
actorUserId: 'user-991199',
acceptedGameTick: 900n,
acceptedClockRevision: 31n,
acceptedDeadlineGeneration: 7n,
payload: {
type: 'messageRespond',
requestId: scoutRequestId,
userId: 'user-991199',
generalId: 991_199,
messageId: scoutMessage.id,
response: true,
},
},
{
requestId: gameplayRequestId,
target: 'ENGINE',
@@ -451,10 +665,15 @@ integration('database command queue', () => {
generalId: 991_199,
messageId: message.id,
response: true,
processingGameTick: 900,
requestedAtWall: expect.any(Date),
},
]);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } })
).resolves.toMatchObject({ status: 'PENDING' });
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: scoutRequestId } })).resolves.toMatchObject({
status: 'PENDING',
});
});
});
@@ -165,6 +165,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
db = connector.prisma;
disconnect = () => connector.disconnect();
await dropFailureConstraints();
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
@@ -198,6 +199,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
await hooks?.close();
if (db) {
await dropFailureConstraints();
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
@@ -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(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: actorUserId, key: 'previous' } },
@@ -299,6 +307,9 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
await expect(
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
).resolves.toBe(messageCount);
await expect(
db.inheritanceLedger.count({ where: { requestId: { startsWith: requestPrefix } } })
).resolves.toBe(ledgerCount);
};
const pointCommand = buildCommand('point', {
@@ -315,7 +326,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 });
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff');
await assertStored(10_000, 17, 0, 0);
await assertStored(10_000, 17, 0, 0, 0);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
).resolves.toMatchObject({
@@ -324,7 +335,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
});
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 });
await assertStored(9_800, 217, 1, 0);
await assertStored(9_800, 217, 1, 0, 1);
const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
await createInputEvent(rankCommand);
@@ -335,14 +346,14 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
`);
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
expect(world.peekDirtyState().messages).toEqual([]);
await assertStored(9_800, 217, 1, 0);
await assertStored(9_800, 217, 1, 0, 1);
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
await expect(execute(rankCommand)).resolves.toMatchObject({
ok: true,
remainPoint: 8_800,
ownerName: '레거시 소유자',
});
await assertStored(8_800, 1_217, 2, 2);
await assertStored(8_800, 1_217, 2, 2, 2);
const currentLog = await db.inheritanceLog.findFirstOrThrow({
where: { userId: actorUserId },
@@ -358,10 +369,10 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
`);
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique');
await assertStored(8_800, 1_217, 2, 2);
await assertStored(8_800, 1_217, 2, 2, 2);
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 3, 2);
await assertStored(5_800, 4_217, 3, 2, 3);
const freeStatCommand = buildCommand('free-stat', {
action: 'resetStat',
@@ -372,7 +383,21 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
});
await createInputEvent(freeStatCommand);
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 5, 2);
await assertStored(5_800, 4_217, 5, 2, 4);
const ledgers = await db.inheritanceLedger.findMany({
where: { requestId: { startsWith: requestPrefix } },
orderBy: { id: 'asc' },
});
expect(ledgers.map(({ action, cost, status }) => ({ action, cost, status }))).toEqual([
{ action: 'buyHiddenBuff', cost: 200, status: 'APPLIED' },
{ action: 'checkOwner', cost: 1_000, status: 'APPLIED' },
{ action: 'buyRandomUnique', cost: 3_000, status: 'APPLIED' },
{ action: 'resetStat', cost: 0, status: 'APPLIED' },
]);
expect(ledgers.every((row) => row.consumedAtWall instanceof Date && row.createdAtWall instanceof Date)).toBe(
true
);
const messages = await db.message.findMany({
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
@@ -133,6 +133,7 @@ describe('input event atomicity', () => {
ok: true,
auctionId: 3,
closeAt: '2026-01-01T00:10:00.000Z',
closeTick: 3_600_000,
};
let resolveResponse: (() => void) | undefined;
const responded = new Promise<void>((resolve) => {
@@ -806,6 +806,8 @@ describeDb('scenario database seed', () => {
amount: 1,
eventId: marker,
eventAt: new Date('2033-01-01T00:00:00.000Z'),
occurredGameTick: 0n,
requestedAtWall: new Date('2033-01-01T00:00:00.000Z'),
},
});
const bettingId = 990_731;
@@ -224,7 +224,7 @@ describe('selection-pool reservation command state', () => {
const rows = buildRows();
const world = buildWorld(rows);
const db = buildDb(rows);
const reserve = (userId: string, acceptedGameTick: number) =>
const reserve = (userId: string, processingGameTick: number) =>
reserveSelectionPool({
db: db as never,
world,
@@ -232,7 +232,7 @@ describe('selection-pool reservation command state', () => {
userId,
seedOwnerIdentity: userId,
now: acceptedAt,
acceptedGameTick,
processingGameTick,
});
const first = await reserve('first-user', 0);
@@ -267,7 +267,7 @@ describe('selection-pool reservation command state', () => {
rows[1]!.reservedUntilTick = 0n;
const world = buildWorld(rows);
const db = buildDb(rows);
const reserve = (userId: string, acceptedGameTick: number) =>
const reserve = (userId: string, processingGameTick: number) =>
reserveSelectionPool({
db: db as never,
world,
@@ -275,7 +275,7 @@ describe('selection-pool reservation command state', () => {
userId,
seedOwnerIdentity: userId,
now: acceptedAt,
acceptedGameTick,
processingGameTick,
});
const first = await reserve('first-user', 0);
+29 -60
View File
@@ -12,7 +12,6 @@ import {
} from '@sammo-ts/logic';
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 { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js';
@@ -81,38 +80,12 @@ const buildDefaultUniquePoolSnapshot = (general: TurnGeneral): TurnWorldSnapshot
});
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');
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, deadline, 0)).toBe(false);
expect(
hasVotePollDeadlinePassed(
{ 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,
});
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, 0)).toBe(true);
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 0)).toBe(false);
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 1)).toBe(true);
});
it('applies gold, unique item, logs, and idempotency', async () => {
@@ -290,9 +263,7 @@ describe('voteReward command', () => {
voteId: 1,
generalId: 1,
selection: [0],
// Ref accepts the request at exact equality. Engine processing may
// occur after the logical clock has advanced beyond the deadline.
acceptedGameTick: 0,
processingGameTick: 0,
};
const writerWindowStart = Date.now();
@@ -418,29 +389,26 @@ describe('voteReward command', () => {
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
);
const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld });
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command;
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, {
db: {
...actorBindingDb(),
$queryRaw: async (query: { strings: readonly string[] }) =>
query.strings.join(' ').includes('SELECT options')
? [
{
options: ['찬성'],
multipleOptions: 1,
endAt: null,
endTick: 0n,
closedAt: null,
},
]
: [],
} as any,
});
expect(legacyLateResult).toMatchObject({
type: 'voteReward',
ok: false,
reason: '설문조사가 종료되었습니다.',
});
const { processingGameTick: _processingGameTick, ...missingBoundaryCommand } = command;
await expect(
legacyLateHandler.handle(missingBoundaryCommand, {
db: {
...actorBindingDb(),
$queryRaw: async (query: { strings: readonly string[] }) =>
query.strings.join(' ').includes('SELECT options')
? [
{
options: ['찬성'],
multipleOptions: 1,
endAt: null,
endTick: 0n,
closedAt: null,
},
]
: [],
} as any,
})
).rejects.toThrow('authoritative daemon processing game tick');
});
it.each([
@@ -501,8 +469,8 @@ describe('voteReward command', () => {
voteId,
generalId: 1,
selection: [0],
acceptedGameTick,
},
processingGameTick: acceptedGameTick,
} as any,
{ db: commandDb as any }
);
@@ -602,7 +570,8 @@ describe('voteReward command', () => {
voteId: 1,
generalId: 1,
selection: [0],
},
processingGameTick: 0,
} as any,
{ db: commandDb as any }
);