시간 도메인과 정지 중 메시지·베팅 경계 정리
This commit is contained in:
@@ -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> => {
|
||||
|
||||
@@ -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}
|
||||
`
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user