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

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 '설문조사가 종료되었습니다.';
}