시간 도메인과 정지 중 메시지·베팅 경계 정리
This commit is contained in:
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
|
||||
generalId: number,
|
||||
input: OpenAuctionInput,
|
||||
requestId?: string
|
||||
): Promise<{ auctionId: number; closeAt: string }> => {
|
||||
): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'auctionOpen',
|
||||
...(requestId ? { requestId } : {}),
|
||||
@@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async (
|
||||
const closeAt = new Date(result.closeAt);
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, closeAt, BigInt(result.closeTick)), value: String(result.auctionId) },
|
||||
]);
|
||||
return {
|
||||
auctionId: result.auctionId,
|
||||
closeAt: result.closeAt,
|
||||
closeTick: result.closeTick,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,18 +10,19 @@ interface RedisSortedSetClient {
|
||||
}
|
||||
|
||||
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
|
||||
if (closeTick !== null && closeTick !== undefined) {
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
return value;
|
||||
}
|
||||
return time.dateToTick(closeAt) ?? closeAt.getTime();
|
||||
void time;
|
||||
void closeAt;
|
||||
if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
|
||||
if (row.status === 'FINALIZING') {
|
||||
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
|
||||
return time.tick ?? time.now.getTime();
|
||||
if (time.tick === null) throw new Error('Current game tick is required for auction recovery.');
|
||||
return time.tick;
|
||||
}
|
||||
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
|
||||
|
||||
interface AuctionFinalizeDeadline {
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
closeTick: bigint;
|
||||
}
|
||||
|
||||
interface AuctionFinalizeCommand {
|
||||
@@ -56,19 +56,10 @@ interface AuctionFinalizeCommand {
|
||||
requestId: string;
|
||||
auctionId: number;
|
||||
expectedCloseAt: string;
|
||||
expectedCloseTick?: number;
|
||||
expectedCloseTick: number;
|
||||
}
|
||||
|
||||
interface AuctionFinalizeEventRecord {
|
||||
target: string;
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
status: string;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
|
||||
if (closeTick === null) return undefined;
|
||||
const readSafeCloseTick = (closeTick: bigint): number => {
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
@@ -81,14 +72,7 @@ export const buildAuctionFinalizeRequestId = (
|
||||
deadline: AuctionFinalizeDeadline,
|
||||
retry = 0
|
||||
): string => {
|
||||
const generation =
|
||||
deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`;
|
||||
const base = `auction:finalize:${auctionId}:${generation}`;
|
||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||
};
|
||||
|
||||
const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
|
||||
const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`;
|
||||
const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
|
||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||
};
|
||||
|
||||
@@ -101,7 +85,7 @@ const buildAuctionFinalizeCommand = (
|
||||
requestId,
|
||||
auctionId,
|
||||
expectedCloseAt: deadline.closeAt.toISOString(),
|
||||
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }),
|
||||
expectedCloseTick: readSafeCloseTick(deadline.closeTick),
|
||||
});
|
||||
|
||||
const isMatchingAuctionFinalizeEvent = (
|
||||
@@ -113,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = (
|
||||
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
const expectedGenerationMatches =
|
||||
payloadRecord?.expectedCloseTick !== undefined
|
||||
? payloadRecord.expectedCloseTick === command.expectedCloseTick
|
||||
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
|
||||
const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
|
||||
return (
|
||||
event.target === 'ENGINE' &&
|
||||
event.eventType === command.type &&
|
||||
@@ -192,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: {
|
||||
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
|
||||
continue;
|
||||
}
|
||||
if (row.closeTick === null) throw new Error(`Auction ${row.id} has no GAME_TIME close authority.`);
|
||||
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
|
||||
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
|
||||
const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt);
|
||||
const bases = [...new Set([canonicalBase, legacyBase])];
|
||||
const events = await options.db.inputEvent.findMany({
|
||||
where: {
|
||||
OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]),
|
||||
OR: [{ requestId: canonicalBase }, { requestId: { startsWith: `${canonicalBase}:retry:` } }],
|
||||
},
|
||||
select: { requestId: true, target: true, eventType: true, payload: true, status: true },
|
||||
orderBy: { sequence: 'desc' },
|
||||
@@ -217,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: {
|
||||
timers.push({
|
||||
score:
|
||||
row.status === 'FINALIZING'
|
||||
? (options.gameTime.tick ?? options.gameTime.now.getTime())
|
||||
? (() => {
|
||||
if (options.gameTime.tick === null) throw new Error('Current game tick is required.');
|
||||
return options.gameTime.tick;
|
||||
})()
|
||||
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
|
||||
value: String(row.id),
|
||||
});
|
||||
@@ -281,10 +264,10 @@ export const processDueAuctionId = async (options: {
|
||||
return { status: 'IGNORED' as const };
|
||||
}
|
||||
if (current.status === 'OPEN') {
|
||||
const isDue =
|
||||
current.closeTick !== null && nowTick !== null
|
||||
? current.closeTick <= BigInt(nowTick)
|
||||
: current.closeTick === null && current.closeAt.getTime() <= now.getTime();
|
||||
if (current.closeTick === null || nowTick === null) {
|
||||
throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
|
||||
}
|
||||
const isDue = current.closeTick <= BigInt(nowTick);
|
||||
if (!isDue) {
|
||||
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
|
||||
}
|
||||
@@ -293,24 +276,15 @@ export const processDueAuctionId = async (options: {
|
||||
return { status: 'IGNORED' as const };
|
||||
}
|
||||
|
||||
if (current.closeTick === null) throw new Error(`Auction ${auctionId} has no GAME_TIME close authority.`);
|
||||
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
|
||||
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
|
||||
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
|
||||
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
|
||||
const candidateRequestIds = [...new Set([requestId, legacyRequestId])];
|
||||
let existing: AuctionFinalizeEventRecord | null = null;
|
||||
let existingRequestId = requestId;
|
||||
for (const candidateRequestId of candidateRequestIds) {
|
||||
existing = await transaction.inputEvent.findUnique({
|
||||
where: { requestId: candidateRequestId },
|
||||
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
||||
});
|
||||
if (existing) {
|
||||
existingRequestId = candidateRequestId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
|
||||
const existing = await transaction.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
||||
});
|
||||
const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||
if (!existing) {
|
||||
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||
await transaction.inputEvent.create({
|
||||
@@ -319,26 +293,19 @@ export const processDueAuctionId = async (options: {
|
||||
target: 'ENGINE',
|
||||
eventType: nextCommand.type,
|
||||
payload: { ...nextCommand },
|
||||
...(nowTick === null ? {} : { acceptedGameTick: BigInt(nowTick) }),
|
||||
...(options.expectedClockRevision === undefined
|
||||
? {}
|
||||
: { acceptedClockRevision: BigInt(options.expectedClockRevision) }),
|
||||
...(options.expectedDeadlineGeneration === undefined
|
||||
? {}
|
||||
: { acceptedDeadlineGeneration: BigInt(options.expectedDeadlineGeneration) }),
|
||||
},
|
||||
});
|
||||
return { status: 'PENDING' as const };
|
||||
}
|
||||
if (!isMatchingAuctionFinalizeEvent(existing, command)) {
|
||||
throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`);
|
||||
throw new Error(`Conflicting durable auction finalization event: ${requestId}`);
|
||||
}
|
||||
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
|
||||
return { status: 'PENDING' as const };
|
||||
}
|
||||
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
|
||||
throw new Error(
|
||||
`Auction remained ${current.status} after successful durable event: ${existingRequestId}`
|
||||
`Auction remained ${current.status} after successful durable event: ${requestId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -386,12 +353,13 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() },
|
||||
]);
|
||||
|
||||
let nextResyncAt = Date.now();
|
||||
let nextResyncAt = performance.now();
|
||||
const pendingFinalizationIds = new Set<number>();
|
||||
|
||||
try {
|
||||
while (!control.signal.aborted) {
|
||||
const operationalNowMs = Date.now();
|
||||
const operationalElapsedMs = performance.now();
|
||||
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
|
||||
const gameNowMs = gameTime.now.getTime();
|
||||
const dueScore = gameTime.tick ?? gameNowMs;
|
||||
@@ -406,9 +374,9 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
|
||||
continue;
|
||||
}
|
||||
if (operationalNowMs >= nextResyncAt) {
|
||||
if (operationalElapsedMs >= nextResyncAt) {
|
||||
await seedAuctionTimers(postgres.prisma, redis.client, keys);
|
||||
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
|
||||
nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
|
||||
}
|
||||
if (pendingFinalizationIds.size > 0) {
|
||||
const reconciliation = await reconcilePendingAuctionTimers({
|
||||
@@ -483,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
await closeResources();
|
||||
}
|
||||
};
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
readInputEventClockCoordinate,
|
||||
type DatabaseClient,
|
||||
type GamePrisma,
|
||||
type InputEventClockCoordinate,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import type { TurnDaemonTransport } from './transport.js';
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
@@ -88,9 +87,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
||||
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
||||
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
||||
if (durableCommand.type === 'npcPossessGeneral') {
|
||||
delete durableCommand.acceptedGameAt;
|
||||
}
|
||||
// Rolling-upgrade compatibility: older API versions supplied game
|
||||
// coordinates. They are deliberately not persisted as command facts;
|
||||
// the daemon assigns the authoritative processing coordinate while
|
||||
// claiming the input event under the clock fence.
|
||||
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameAt;
|
||||
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameTick;
|
||||
if (command.type === 'npcPossessGeneral') {
|
||||
const existing = await this.db.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
@@ -112,12 +114,11 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
const coordinate = await readInputEventClockCoordinate(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
|
||||
const acceptedGameAt = coordinate.gameAt;
|
||||
const token = await transaction.npcSelectionToken.findFirst({
|
||||
where: {
|
||||
ownerUserId: command.userId,
|
||||
nonce: command.tokenNonce,
|
||||
validUntil: { gte: acceptedGameAt },
|
||||
validUntilTick: { gte: coordinate.gameTick },
|
||||
},
|
||||
select: { pickResult: true },
|
||||
});
|
||||
@@ -132,11 +133,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
) {
|
||||
return '선택한 장수가 목록에 없습니다.';
|
||||
}
|
||||
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = {
|
||||
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
|
||||
acceptedGameAt: acceptedGameAt.toISOString(),
|
||||
};
|
||||
await this.createInputEvent(transaction, acceptedCommand, requestId, coordinate);
|
||||
await this.createInputEvent(transaction, durableCommand, requestId);
|
||||
return null;
|
||||
});
|
||||
if (rejectionReason) {
|
||||
@@ -145,8 +142,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
} else {
|
||||
if (this.db.$transaction) {
|
||||
await this.db.$transaction(async (transaction) => {
|
||||
const coordinate = await readInputEventClockCoordinate(transaction);
|
||||
await this.createInputEvent(transaction, durableCommand, requestId, coordinate);
|
||||
await this.createInputEvent(transaction, durableCommand, requestId);
|
||||
});
|
||||
} else {
|
||||
await this.createInputEvent(this.db, durableCommand, requestId);
|
||||
@@ -178,21 +174,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
private async createInputEvent(
|
||||
db: DatabaseClient,
|
||||
command: TurnDaemonCommand,
|
||||
requestId: string,
|
||||
coordinate?: InputEventClockCoordinate
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
const gameTime = coordinate ? null : await loadCurrentGameTime(db, new Date());
|
||||
const commandAcceptedTick = Reflect.get(command, 'acceptedGameTick');
|
||||
const acceptedGameTick =
|
||||
typeof commandAcceptedTick === 'number' && Number.isSafeInteger(commandAcceptedTick)
|
||||
? commandAcceptedTick
|
||||
: coordinate
|
||||
? Number(coordinate.gameTick)
|
||||
: gameTime!.tick;
|
||||
const acceptedClockRevision = coordinate ? Number(coordinate.clockRevision) : gameTime!.revision;
|
||||
const acceptedDeadlineGeneration = coordinate
|
||||
? Number(coordinate.deadlineGeneration)
|
||||
: gameTime!.deadlineGeneration;
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
@@ -200,14 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
eventType: command.type,
|
||||
payload: asJson(command),
|
||||
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
|
||||
...(acceptedGameTick === null ? {} : { acceptedGameTick: BigInt(acceptedGameTick) }),
|
||||
...(acceptedClockRevision === null || acceptedClockRevision === undefined
|
||||
? {}
|
||||
: { acceptedClockRevision: BigInt(acceptedClockRevision) }),
|
||||
...(acceptedDeadlineGeneration === null || acceptedDeadlineGeneration === undefined
|
||||
? {}
|
||||
: { acceptedDeadlineGeneration: BigInt(acceptedDeadlineGeneration) }),
|
||||
...(coordinate ? { createdAt: coordinate.wallAt } : {}),
|
||||
// PostgreSQL owns created_at WALL_TIME. ENGINE assigns the
|
||||
// authoritative game coordinate when the daemon claims it.
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -224,8 +201,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
}
|
||||
|
||||
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
|
||||
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
|
||||
while (performance.now() < deadline) {
|
||||
const event = await this.db.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
select: { status: true, result: true, error: true },
|
||||
@@ -236,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
if (event?.status === 'FAILED') {
|
||||
throw new FailedTurnDaemonCommandError(requestId, event.error);
|
||||
}
|
||||
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
|
||||
await delay(Math.min(50, Math.max(1, deadline - performance.now())));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ interface LockedInputEvent {
|
||||
status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED';
|
||||
result: GamePrisma.JsonValue | null;
|
||||
attempts: number;
|
||||
acceptedGameTick: bigint | null;
|
||||
acceptedClockRevision: bigint | null;
|
||||
acceptedDeadlineGeneration: bigint | null;
|
||||
}
|
||||
|
||||
type InputEventOutcome<T> =
|
||||
@@ -37,8 +34,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
|
||||
$executeRawUnsafe(query: string): Promise<number>;
|
||||
};
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
const canonicalJson = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, entry: unknown) => {
|
||||
if (typeof entry === 'bigint') {
|
||||
@@ -106,9 +101,9 @@ const insertPendingIfAbsent = async (
|
||||
${options.actorUserId},
|
||||
'PENDING'::"InputEventStatus",
|
||||
0,
|
||||
(SELECT clock_tick FROM world_state ORDER BY id ASC LIMIT 1),
|
||||
(SELECT clock_revision FROM world_state ORDER BY id ASC LIMIT 1),
|
||||
(SELECT deadline_generation FROM world_state ORDER BY id ASC LIMIT 1),
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
ON CONFLICT (request_id) DO NOTHING
|
||||
@@ -126,10 +121,7 @@ const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise<Lo
|
||||
actor_user_id AS "actorUserId",
|
||||
status,
|
||||
result,
|
||||
attempts,
|
||||
accepted_game_tick AS "acceptedGameTick",
|
||||
accepted_clock_revision AS "acceptedClockRevision",
|
||||
accepted_deadline_generation AS "acceptedDeadlineGeneration"
|
||||
attempts
|
||||
FROM input_event
|
||||
WHERE request_id = ${requestId}
|
||||
FOR UPDATE
|
||||
@@ -168,26 +160,24 @@ const isMatchingIdentity = (
|
||||
const claimInputEvent = async (
|
||||
db: DatabaseClient,
|
||||
requestId: string,
|
||||
payloadIdentity: ApiInputPayloadIdentity,
|
||||
row: LockedInputEvent
|
||||
payloadIdentity: ApiInputPayloadIdentity
|
||||
): Promise<void> => {
|
||||
await db.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
payload: asJson(payloadIdentity),
|
||||
status: 'PROCESSING',
|
||||
result: GamePrisma.DbNull,
|
||||
error: null,
|
||||
attempts: { increment: 1 },
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
processingAt: new Date(),
|
||||
processingGameTick: row.acceptedGameTick,
|
||||
processingClockRevision: row.acceptedClockRevision,
|
||||
processingDeadlineGeneration: row.acceptedDeadlineGeneration,
|
||||
completedAt: null,
|
||||
},
|
||||
});
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
|
||||
status = 'PROCESSING'::"InputEventStatus",
|
||||
result = NULL,
|
||||
error = NULL,
|
||||
attempts = attempts + 1,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL,
|
||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
processing_game_tick = NULL,
|
||||
processing_clock_revision = NULL,
|
||||
processing_deadline_generation = NULL,
|
||||
completed_at = NULL
|
||||
WHERE request_id = ${requestId}
|
||||
`);
|
||||
};
|
||||
|
||||
const markUnexpectedFailure = async (
|
||||
@@ -198,6 +188,7 @@ const markUnexpectedFailure = async (
|
||||
actorUserId: string | null;
|
||||
payloadIdentity: ApiInputPayloadIdentity;
|
||||
error: unknown;
|
||||
acquireClockFence: boolean;
|
||||
}
|
||||
): Promise<void> => {
|
||||
if (!db.$transaction) return;
|
||||
@@ -205,7 +196,9 @@ const markUnexpectedFailure = async (
|
||||
|
||||
try {
|
||||
await db.$transaction(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
if (options.acquireClockFence) {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
}
|
||||
await insertPendingIfAbsent(transaction, options);
|
||||
const row = await lockInputEvent(transaction, options.requestId);
|
||||
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
|
||||
@@ -214,20 +207,19 @@ const markUnexpectedFailure = async (
|
||||
// late failure recorder must never replace its durable success.
|
||||
return;
|
||||
}
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId: options.requestId },
|
||||
data: {
|
||||
payload: asJson(options.payloadIdentity),
|
||||
status: 'FAILED',
|
||||
result: GamePrisma.DbNull,
|
||||
error: message,
|
||||
attempts: { increment: 1 },
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
processingAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
|
||||
status = 'FAILED'::"InputEventStatus",
|
||||
result = NULL,
|
||||
error = ${message},
|
||||
attempts = attempts + 1,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL,
|
||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE request_id = ${options.requestId}
|
||||
`);
|
||||
});
|
||||
} catch {
|
||||
// Preserve the transaction failure that the caller actually observed. If
|
||||
@@ -242,10 +234,12 @@ export const executeInputEvent = async <T>(options: {
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
actorUserId?: string | null;
|
||||
acquireClockFence?: boolean;
|
||||
execute(db: DatabaseClient): Promise<T>;
|
||||
}): Promise<T> => {
|
||||
const { db, requestId, eventType, payload, execute } = options;
|
||||
const actorUserId = options.actorUserId ?? null;
|
||||
const acquireClockFence = options.acquireClockFence !== false;
|
||||
const payloadIdentity = createApiInputPayloadIdentity(payload);
|
||||
if (!db.$transaction) {
|
||||
return execute(db);
|
||||
@@ -255,7 +249,9 @@ export const executeInputEvent = async <T>(options: {
|
||||
let outcome: InputEventOutcome<T>;
|
||||
try {
|
||||
outcome = await db.$transaction(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
if (acquireClockFence) {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
}
|
||||
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
|
||||
const row = await lockInputEvent(transaction, requestId);
|
||||
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
|
||||
@@ -274,43 +270,48 @@ export const executeInputEvent = async <T>(options: {
|
||||
throw new DuplicateInputEventError(requestId);
|
||||
}
|
||||
|
||||
await claimInputEvent(transaction, requestId, payloadIdentity, row);
|
||||
await claimInputEvent(transaction, requestId, payloadIdentity);
|
||||
const savepointDb = transaction as SavepointDatabaseClient;
|
||||
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
businessStarted = true;
|
||||
try {
|
||||
const value = await execute(transaction);
|
||||
const durableResult = canonicalJsonValue(value);
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
result: asJson(durableResult),
|
||||
error: null,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'SUCCEEDED'::"InputEventStatus",
|
||||
result = CAST(${JSON.stringify(durableResult)} AS jsonb),
|
||||
error = NULL,
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE request_id = ${requestId}
|
||||
`);
|
||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
return { kind: 'executed', value };
|
||||
} catch (error) {
|
||||
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
result: GamePrisma.DbNull,
|
||||
error: message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'FAILED'::"InputEventStatus",
|
||||
result = NULL,
|
||||
error = ${message},
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE request_id = ${requestId}
|
||||
`);
|
||||
return { kind: 'failed', error };
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
|
||||
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error });
|
||||
await markUnexpectedFailure(db, {
|
||||
requestId,
|
||||
eventType,
|
||||
actorUserId,
|
||||
payloadIdentity,
|
||||
error,
|
||||
acquireClockFence,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
enqueuePrivateMessageWebPush,
|
||||
GamePrisma,
|
||||
persistMessageEnvelope,
|
||||
type MessageGameContext,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
|
||||
export interface MessageView {
|
||||
id: number;
|
||||
@@ -22,7 +26,9 @@ interface MessageRow {
|
||||
src: number;
|
||||
dest: number;
|
||||
time: Date;
|
||||
valid_until: Date;
|
||||
created_at_wall: Date;
|
||||
action_status: string | null;
|
||||
expires_game_tick: bigint | null;
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
@@ -48,70 +54,66 @@ const formatMessageTime = (value: Date): string => {
|
||||
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||
};
|
||||
|
||||
const messageValidityPredicate = (gameTime: CurrentGameTime) => {
|
||||
if (gameTime.tick === null) {
|
||||
// A legacy or partially migrated profile has no authoritative logical
|
||||
// tick. Rows that already carry a tick still need the wall-time
|
||||
// fallback used by the clock migration.
|
||||
return GamePrisma.sql`valid_until > ${gameTime.now}`;
|
||||
}
|
||||
return GamePrisma.sql`(
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
|
||||
)`;
|
||||
};
|
||||
|
||||
const toMessageView = (row: MessageRow): MessageView => {
|
||||
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
||||
const payload = parsePayload(row.message);
|
||||
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
|
||||
const actionUnavailable =
|
||||
actionStatus !== null &&
|
||||
(actionStatus !== 'PENDING' ||
|
||||
(row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick));
|
||||
return {
|
||||
id: row.id,
|
||||
msgType: row.type,
|
||||
src: payload.src,
|
||||
dest: row.type === 'public' ? null : payload.dest,
|
||||
text: payload.text,
|
||||
option: payload.option ?? null,
|
||||
time: formatMessageTime(new Date(row.time)),
|
||||
option:
|
||||
actionUnavailable && payload.option && typeof payload.option === 'object'
|
||||
? { ...payload.option, used: true, invalid: true }
|
||||
: (payload.option ?? null),
|
||||
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
|
||||
};
|
||||
};
|
||||
|
||||
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const toTickOrNull = (date: Date): bigint | null => {
|
||||
// Ref represents its unlimited 9999-12-31 message lifetime with the
|
||||
// largest safe game tick instead of falling back to a wall-clock-only row.
|
||||
if (date.getUTCFullYear() >= 9000) {
|
||||
return BigInt(MAX_SAFE_GAME_TICK);
|
||||
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
|
||||
let gameContext: MessageGameContext | null = null;
|
||||
if (typeof action === 'string' && action !== '') {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
if (
|
||||
gameTime.tick === null ||
|
||||
gameTime.revision === null ||
|
||||
gameTime.revision === undefined ||
|
||||
gameTime.deadlineGeneration === null ||
|
||||
gameTime.deadlineGeneration === undefined
|
||||
) {
|
||||
throw new Error(`Actionable message ${action} requires an initialized game clock.`);
|
||||
}
|
||||
try {
|
||||
const tick = gameTime.dateToTick(date);
|
||||
return tick === null ? null : BigInt(tick);
|
||||
} catch {
|
||||
return null;
|
||||
let expiresGameTick: bigint | null = null;
|
||||
if (draft.validUntil.getUTCFullYear() < 9000) {
|
||||
const expires = gameTime.dateToTick(draft.validUntil);
|
||||
if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
|
||||
expiresGameTick = BigInt(expires);
|
||||
}
|
||||
};
|
||||
const rows = await db.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${toTickOrNull(draft.time)},
|
||||
${draft.validUntil},
|
||||
${toTickOrNull(draft.validUntil)},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error('Failed to insert message row.');
|
||||
gameContext = {
|
||||
occurredGameTick: BigInt(gameTime.tick),
|
||||
clockRevision: BigInt(gameTime.revision),
|
||||
deadlineGeneration: BigInt(gameTime.deadlineGeneration),
|
||||
expiresGameTick,
|
||||
};
|
||||
}
|
||||
const id = await persistMessageEnvelope(db, draft, gameContext);
|
||||
await enqueuePrivateMessageWebPush(db, draft, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const loadMessageViews = async (db: DatabaseClient, rows: MessageRow[]): Promise<MessageView[]> => {
|
||||
if (!rows.some((row) => typeof row.action_status === 'string')) return rows.map((row) => toMessageView(row, null));
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const currentGameTick = gameTime.tick === null ? null : BigInt(gameTime.tick);
|
||||
return rows.map((row) => toMessageView(row, currentGameTick));
|
||||
};
|
||||
|
||||
export const fetchMessagesFromMailbox = async (params: {
|
||||
db: DatabaseClient;
|
||||
mailbox: number;
|
||||
@@ -120,19 +122,20 @@ export const fetchMessagesFromMailbox = async (params: {
|
||||
fromSeq: number;
|
||||
}): Promise<MessageView[]> => {
|
||||
const fromSeq = Math.max(params.fromSeq, 0);
|
||||
const gameTime = await loadCurrentGameTime(params.db);
|
||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
AND id >= ${fromSeq}
|
||||
ORDER BY id DESC
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||
WHERE m.mailbox = ${params.mailbox}
|
||||
AND m.type = ${params.msgType}
|
||||
AND m.id >= ${fromSeq}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ${params.limit}
|
||||
`;
|
||||
|
||||
return rows.map(toMessageView);
|
||||
return loadMessageViews(params.db, rows);
|
||||
};
|
||||
|
||||
export const fetchOldMessagesFromMailbox = async (params: {
|
||||
@@ -142,28 +145,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
|
||||
toSeq: number;
|
||||
limit: number;
|
||||
}): Promise<MessageView[]> => {
|
||||
const gameTime = await loadCurrentGameTime(params.db);
|
||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
AND id < ${params.toSeq}
|
||||
ORDER BY id DESC
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||
WHERE m.mailbox = ${params.mailbox}
|
||||
AND m.type = ${params.msgType}
|
||||
AND m.id < ${params.toSeq}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ${params.limit}
|
||||
`;
|
||||
|
||||
return rows.map(toMessageView);
|
||||
return loadMessageViews(params.db, rows);
|
||||
};
|
||||
|
||||
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const rows = await db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE id = ${id}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||
WHERE m.id = ${id}
|
||||
LIMIT 1
|
||||
`;
|
||||
const row = rows[0];
|
||||
@@ -172,20 +177,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
|
||||
id: row.id,
|
||||
mailbox: row.mailbox,
|
||||
msgType: row.type,
|
||||
time: new Date(row.time),
|
||||
time: new Date(row.created_at_wall ?? row.time),
|
||||
payload: parsePayload(row.message),
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
if (gameTime.tick === null) throw new Error('Actionable message response requires an initialized game clock.');
|
||||
const rows = await db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE id = ${id}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
JOIN message_action ma ON ma.message_id = m.id
|
||||
JOIN world_state world ON TRUE
|
||||
WHERE m.id = ${id}
|
||||
AND ma.status = 'PENDING'
|
||||
AND (ma.expires_game_tick IS NULL OR ma.expires_game_tick > ${BigInt(gameTime.tick)})
|
||||
AND world.clock_phase IN ('RUNNING', 'MANUAL')
|
||||
AND ma.clock_revision = world.clock_revision
|
||||
AND ma.deadline_generation = world.deadline_generation
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
FOR UPDATE OF m, ma, world
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
@@ -193,7 +207,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
|
||||
id: row.id,
|
||||
mailbox: row.mailbox,
|
||||
msgType: row.type,
|
||||
time: new Date(row.time),
|
||||
time: new Date(row.created_at_wall ?? row.time),
|
||||
payload: parsePayload(row.message),
|
||||
};
|
||||
};
|
||||
@@ -202,16 +216,16 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
|
||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||
if (uniqueIds.length === 0) return;
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
if (gameTime.tick === null) throw new Error('Actionable message invalidation requires an initialized game clock.');
|
||||
await db.messageAction.updateMany({
|
||||
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick: BigInt(gameTime.tick) },
|
||||
});
|
||||
await db.message.updateMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
data: {
|
||||
validUntil: gameTime.now,
|
||||
// A partially migrated profile can still carry a legacy logical
|
||||
// sentinel even while no authoritative clock exists. Replace it
|
||||
// with an already-expired logical tick when expiring by wall time;
|
||||
// NULL would fall back to the wall timestamp after clock recovery
|
||||
// and could make the handled message visible again.
|
||||
validUntilTick: gameTime.tick === null ? 0n : BigInt(gameTime.tick),
|
||||
validUntilTick: BigInt(gameTime.tick),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -233,8 +247,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
|
||||
END
|
||||
) || jsonb_build_object('invalid', true),
|
||||
true
|
||||
)
|
||||
),
|
||||
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id IN (${GamePrisma.join(uniqueIds)})
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
export const tombstoneMessagesWithinDeleteWindow = async (
|
||||
db: DatabaseClient,
|
||||
authorityMessageId: number,
|
||||
ids: number[]
|
||||
): Promise<number[]> => {
|
||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const rows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
WITH wall AS (
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
|
||||
), authority AS (
|
||||
SELECT m.id
|
||||
FROM message m, wall
|
||||
WHERE m.id = ${authorityMessageId}
|
||||
AND m.tombstoned_at_wall IS NULL
|
||||
AND m.delete_until_wall >= wall.now_wall
|
||||
FOR UPDATE
|
||||
)
|
||||
UPDATE message m
|
||||
SET message = jsonb_set(
|
||||
jsonb_set(m.message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
|
||||
'{option}',
|
||||
(
|
||||
CASE
|
||||
WHEN jsonb_typeof(m.message->'option') = 'object' THEN m.message->'option'
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
) || jsonb_build_object('invalid', true),
|
||||
true
|
||||
),
|
||||
tombstoned_at_wall = wall.now_wall
|
||||
FROM wall
|
||||
WHERE m.id IN (${GamePrisma.join(uniqueIds)})
|
||||
AND EXISTS (SELECT 1 FROM authority)
|
||||
RETURNING m.id
|
||||
`);
|
||||
return rows.map(({ id }) => id).sort((left, right) => left - right);
|
||||
};
|
||||
|
||||
@@ -60,10 +60,7 @@ export interface AuctionDetail {
|
||||
export const hasAuctionClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
time: { now: Date; tick: number | null }
|
||||
): boolean =>
|
||||
auction.closeTick !== null && time.tick !== null
|
||||
? auction.closeTick < BigInt(time.tick)
|
||||
: auction.closeAt.getTime() < time.now.getTime();
|
||||
): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
|
||||
|
||||
interface AuctionBidRow {
|
||||
id: number;
|
||||
@@ -433,7 +430,6 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
@@ -448,7 +444,7 @@ export const auctionRouter = router({
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const nextCloseAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
@@ -511,7 +507,6 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
@@ -526,7 +521,7 @@ export const auctionRouter = router({
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const nextCloseAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
@@ -650,7 +645,6 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
@@ -665,7 +659,7 @@ export const auctionRouter = router({
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const nextCloseAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
|
||||
return world;
|
||||
};
|
||||
|
||||
interface BettingClockFenceRow {
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
clockPhase: string;
|
||||
clockRevision: bigint;
|
||||
deadlineGeneration: bigint;
|
||||
}
|
||||
|
||||
const lockBettingClockFence = async (db: Parameters<typeof getMyGeneral>[0]['db']): Promise<BettingClockFenceRow> => {
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
const rows = await db.$queryRaw<BettingClockFenceRow[]>(GamePrisma.sql`
|
||||
SELECT current_year AS "currentYear",
|
||||
current_month AS "currentMonth",
|
||||
clock_phase AS "clockPhase",
|
||||
clock_revision AS "clockRevision",
|
||||
deadline_generation AS "deadlineGeneration"
|
||||
FROM world_state
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
const world = rows[0];
|
||||
if (!world) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' });
|
||||
}
|
||||
if (!['RUNNING', 'MANUAL', 'SUSPENDED'].includes(world.clockPhase)) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: `Nation betting is disabled while the game clock phase is ${world.clockPhase}.`,
|
||||
});
|
||||
}
|
||||
return world;
|
||||
};
|
||||
|
||||
export const bettingRouter = router({
|
||||
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
|
||||
async ({ ctx, input }) => {
|
||||
requireUserId(ctx.auth);
|
||||
await getMyGeneral(ctx);
|
||||
const [world, rows] = await Promise.all([
|
||||
@@ -66,7 +100,8 @@ export const bettingRouter = router({
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
};
|
||||
}),
|
||||
}
|
||||
),
|
||||
|
||||
getDetail: authedProcedure
|
||||
.input(z.object({ bettingId: z.number().int().positive() }))
|
||||
@@ -141,7 +176,7 @@ export const bettingRouter = router({
|
||||
if (betting.finished) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
|
||||
}
|
||||
const world = await loadWorldDate(ctx.db);
|
||||
const world = await lockBettingClockFence(ctx.db);
|
||||
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
|
||||
if (betting.closeYearMonth <= yearMonth) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
||||
import { insertMessage } from '../../messages/store.js';
|
||||
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { readDatabaseWallTime } from '../../services/wallClock.js';
|
||||
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
@@ -306,8 +306,6 @@ export const diplomacyRouter = router({
|
||||
nationColor: destNation.color,
|
||||
},
|
||||
};
|
||||
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
|
||||
|
||||
const created = await ctx.db.diplomacyLetter.create({
|
||||
data: {
|
||||
srcNationId: srcNation.id,
|
||||
@@ -316,7 +314,6 @@ export const diplomacyRouter = router({
|
||||
state: 'PROPOSED',
|
||||
textBrief: purifyDiplomacyHtml(input.brief),
|
||||
textDetail: purifyDiplomacyHtml(input.detail),
|
||||
date: letterDate,
|
||||
srcSignerId: me.id,
|
||||
aux: aux as GamePrisma.InputJsonValue,
|
||||
},
|
||||
@@ -332,7 +329,7 @@ export const diplomacyRouter = router({
|
||||
src: srcTarget,
|
||||
dest: destTarget,
|
||||
text,
|
||||
time: letterDate,
|
||||
time: created.date,
|
||||
});
|
||||
|
||||
return { id: created.id };
|
||||
@@ -371,7 +368,7 @@ export const diplomacyRouter = router({
|
||||
);
|
||||
const messageSrc = buildActorTarget(me, destNation);
|
||||
const messageDest = buildNationTarget(srcNation);
|
||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||
const aux = asRecord(letter.aux);
|
||||
let messageText: string;
|
||||
if (input.agree) {
|
||||
@@ -458,7 +455,7 @@ export const diplomacyRouter = router({
|
||||
);
|
||||
const messageSrc = buildActorTarget(me, srcNation);
|
||||
const messageDest = buildNationTarget(destNation);
|
||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||
const aux = asRecord(letter.aux);
|
||||
aux.reason = {
|
||||
who: me.id,
|
||||
@@ -519,7 +516,7 @@ export const diplomacyRouter = router({
|
||||
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
||||
const messageSrc = buildActorTarget(me, actorNation);
|
||||
const messageDest = buildNationTarget(otherNation);
|
||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||
let resultState: 'ACTIVATED' | 'CANCELLED';
|
||||
let messageText: string;
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import { z } from 'zod';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
} from '@sammo-ts/infra';
|
||||
import {
|
||||
isWarTraitKey,
|
||||
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||
@@ -393,15 +398,12 @@ export const joinRouter = router({
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolReserve',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
userId,
|
||||
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
return resolveSelectionReservationCommandResult(result);
|
||||
}),
|
||||
@@ -431,7 +433,6 @@ export const joinRouter = router({
|
||||
});
|
||||
}
|
||||
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolCreate',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
@@ -440,8 +441,6 @@ export const joinRouter = router({
|
||||
uniqueName: input.uniqueName,
|
||||
personality: input.personality,
|
||||
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
...(selectedIcon
|
||||
? {
|
||||
ownerPicture: selectedIcon.picture,
|
||||
@@ -471,15 +470,12 @@ export const joinRouter = router({
|
||||
input.clientRequestId,
|
||||
'reselect'
|
||||
);
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolReselect',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
userId,
|
||||
ownerDisplayName: auth.user.displayName,
|
||||
uniqueName: input.uniqueName,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
||||
}),
|
||||
@@ -583,30 +579,46 @@ export const joinRouter = router({
|
||||
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
if (gameTime.tick === null) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Game clock is not initialized.',
|
||||
return await ctx.db.$transaction!(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
|
||||
SELECT clock_phase AS "clockPhase"
|
||||
FROM world_state
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
if (!clockRows[0]) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const worldState = await transaction.worldState.findFirst();
|
||||
const gameTime = await loadCurrentGameTime(transaction);
|
||||
if (!worldState || gameTime.tick === null) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Game clock is not initialized.',
|
||||
});
|
||||
}
|
||||
return reserveNpcPossessionCandidates({
|
||||
db: transaction,
|
||||
worldState,
|
||||
userId: auth.user.id,
|
||||
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
|
||||
refresh: input.refresh,
|
||||
keepIds: input.keepIds,
|
||||
now: gameTime.now,
|
||||
createdGameTick: gameTime.tick,
|
||||
});
|
||||
}
|
||||
return await reserveNpcPossessionCandidates({
|
||||
db: ctx.db,
|
||||
worldState,
|
||||
userId: auth.user.id,
|
||||
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
|
||||
refresh: input.refresh,
|
||||
keepIds: input.keepIds,
|
||||
now: gameTime.now,
|
||||
acceptedGameTick: gameTime.tick,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NpcPossessionError) {
|
||||
|
||||
@@ -5,7 +5,13 @@ import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
accessLimitAuthedInputProcedure,
|
||||
accessWallAuthedInputProcedure,
|
||||
authedProcedure,
|
||||
router,
|
||||
wallAuthedProcedure,
|
||||
} from '../../trpc.js';
|
||||
import {
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||
MESSAGE_MAILBOX_PUBLIC,
|
||||
@@ -20,13 +26,12 @@ import {
|
||||
fetchOldMessagesFromMailbox,
|
||||
fetchMessageById,
|
||||
insertMessage,
|
||||
tombstoneMessages,
|
||||
tombstoneMessagesWithinDeleteWindow,
|
||||
type MessageView,
|
||||
} from '../../messages/store.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
import { resolveNationPermission } from '../nation/shared.js';
|
||||
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
|
||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||
|
||||
@@ -231,7 +236,7 @@ export const messagesRouter = router({
|
||||
})),
|
||||
};
|
||||
}),
|
||||
readLatest: authedProcedure
|
||||
readLatest: wallAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
@@ -264,7 +269,7 @@ export const messagesRouter = router({
|
||||
`;
|
||||
return { ok: true };
|
||||
}),
|
||||
delete: authedProcedure
|
||||
delete: wallAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
@@ -289,17 +294,16 @@ export const messagesRouter = router({
|
||||
if (message.payload.option?.deletable === false) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
||||
}
|
||||
const { now } = await loadCurrentGameTime(ctx.db);
|
||||
if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||
}
|
||||
const receiverMessageId = message.payload.option?.receiverMessageID;
|
||||
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
||||
const ids = [
|
||||
message.id,
|
||||
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||
];
|
||||
await tombstoneMessages(ctx.db, ids);
|
||||
const deletedIds = await tombstoneMessagesWithinDeleteWindow(ctx.db, message.id, ids);
|
||||
if (!deletedIds.includes(message.id)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||
}
|
||||
const receiverMailbox =
|
||||
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
||||
? message.payload.dest.generalId
|
||||
@@ -309,7 +313,7 @@ export const messagesRouter = router({
|
||||
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
||||
: null;
|
||||
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
||||
return { ok: true, deletedIds: ids };
|
||||
return { ok: true, deletedIds };
|
||||
}),
|
||||
respond: authedProcedure
|
||||
.input(
|
||||
@@ -420,7 +424,7 @@ export const messagesRouter = router({
|
||||
...messageBuckets,
|
||||
};
|
||||
}),
|
||||
send: accessAuthedInputProcedure(
|
||||
send: accessWallAuthedInputProcedure(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
mailbox: z.number().int(),
|
||||
@@ -436,7 +440,9 @@ export const messagesRouter = router({
|
||||
}
|
||||
|
||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
||||
const { now } = await loadCurrentGameTime(ctx.db);
|
||||
// Compatibility-only projection. persistMessageEnvelope records and
|
||||
// displays the authoritative PostgreSQL wall instant.
|
||||
const now = new Date();
|
||||
const validUntil = new Date('9999-12-31T00:00:00Z');
|
||||
|
||||
let msgType: MessageType;
|
||||
|
||||
@@ -8,10 +8,10 @@ import type { TournamentState } from '../../tournament/types.js';
|
||||
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
|
||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
||||
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { ensureActiveRedisClockFence } from '../../services/redisClockFence.js';
|
||||
import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
|
||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
@@ -64,7 +64,7 @@ const withTournamentClockMutation = async <T>(
|
||||
});
|
||||
}
|
||||
const clockContext: TournamentClockContext = {
|
||||
phase: 'RUNNING',
|
||||
phase: fence.phase,
|
||||
revision: fence.revision,
|
||||
deadlineGeneration: fence.generation,
|
||||
dateToTick: gameTime.dateToTick,
|
||||
@@ -72,6 +72,37 @@ const withTournamentClockMutation = async <T>(
|
||||
return store.withClockContext(clockContext, () => store.withMutationLock(operation));
|
||||
};
|
||||
|
||||
const withTournamentBetClockMutation = async <T>(
|
||||
ctx: {
|
||||
db: Parameters<typeof loadCurrentGameTime>[0];
|
||||
redis: Parameters<typeof ensureBettingRedisClockFence>[0];
|
||||
profile: { name: string };
|
||||
},
|
||||
store: TournamentStore,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const fence = await ensureBettingRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
|
||||
if (!fence) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Clock reconciliation is incomplete; tournament betting is disabled.',
|
||||
});
|
||||
}
|
||||
return store.withClockContext(
|
||||
{
|
||||
phase: fence.phase,
|
||||
revision: fence.revision,
|
||||
deadlineGeneration: fence.generation,
|
||||
dateToTick: gameTime.dateToTick,
|
||||
},
|
||||
() => store.withMutationLock(operation)
|
||||
);
|
||||
};
|
||||
|
||||
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
|
||||
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
|
||||
|
||||
const zTournamentState = z.object({
|
||||
stage: z.number().int().min(0),
|
||||
phase: z.number().int().min(0),
|
||||
@@ -544,7 +575,10 @@ export const tournamentRouter = router({
|
||||
return { ok: true };
|
||||
});
|
||||
}),
|
||||
placeBet: authedProcedure
|
||||
// This route delegates its game mutations to durable ENGINE input events.
|
||||
// Wrapping it in the API input-event transaction would hold the clock
|
||||
// advisory lock while waiting for the daemon to claim the child event.
|
||||
placeBet: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
targetId: z.number().int().positive(),
|
||||
@@ -554,7 +588,7 @@ export const tournamentRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
return withTournamentBetClockMutation(ctx, store, async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 6) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
||||
@@ -589,6 +623,7 @@ export const tournamentRouter = router({
|
||||
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||
});
|
||||
@@ -604,6 +639,7 @@ export const tournamentRouter = router({
|
||||
|
||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
@@ -615,6 +651,7 @@ export const tournamentRouter = router({
|
||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
});
|
||||
@@ -631,11 +668,13 @@ export const tournamentRouter = router({
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [
|
||||
{
|
||||
|
||||
@@ -102,18 +102,16 @@ export const hasPollEnded = (
|
||||
time: CurrentGameTime
|
||||
): boolean =>
|
||||
Boolean(poll.closed_at) ||
|
||||
(poll.end_tick !== null && time.tick !== null
|
||||
? poll.end_tick < BigInt(time.tick)
|
||||
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
|
||||
Boolean(
|
||||
poll.end_at &&
|
||||
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
|
||||
);
|
||||
|
||||
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
|
||||
if (!date) return null;
|
||||
try {
|
||||
const tick = time.dateToTick(date);
|
||||
return tick === null ? null : BigInt(tick);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const tick = time.dateToTick(date);
|
||||
if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
|
||||
return BigInt(tick);
|
||||
};
|
||||
|
||||
type VoteListRow = {
|
||||
@@ -358,7 +356,6 @@ export const voteRouter = router({
|
||||
voteId: input.voteId,
|
||||
generalId: general.id,
|
||||
selection: sortedSelection,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
throwIfCommandRejected(rewardResult);
|
||||
|
||||
@@ -399,8 +396,6 @@ export const voteRouter = router({
|
||||
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
|
||||
: null;
|
||||
const nationName = nation?.name ?? '재야';
|
||||
const createdAt = new Date();
|
||||
|
||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||
INSERT INTO vote_comment (
|
||||
vote_id,
|
||||
@@ -418,7 +413,7 @@ export const voteRouter = router({
|
||||
${general.name},
|
||||
${nationName},
|
||||
${input.text},
|
||||
${createdAt}
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -451,7 +446,6 @@ export const voteRouter = router({
|
||||
if (endAt && endAt < gameTime.now) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||
}
|
||||
const operationalAt = new Date();
|
||||
|
||||
let multipleOptions = input.multipleOptions;
|
||||
if (multipleOptions < 0) {
|
||||
@@ -464,7 +458,8 @@ export const voteRouter = router({
|
||||
if (input.closePrevious) {
|
||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt}
|
||||
SET closed_at = ${gameTime.now},
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE closed_at IS NULL
|
||||
`);
|
||||
}
|
||||
@@ -497,8 +492,8 @@ export const voteRouter = router({
|
||||
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
||||
${endAt},
|
||||
${toGameTickOrNull(gameTime, endAt)},
|
||||
${operationalAt},
|
||||
${operationalAt}
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -573,7 +568,6 @@ export const voteRouter = router({
|
||||
if (endAt && endAt < gameTime.now) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||
}
|
||||
const updatedAt = new Date();
|
||||
|
||||
if (
|
||||
input.title === undefined &&
|
||||
@@ -596,7 +590,7 @@ export const voteRouter = router({
|
||||
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
|
||||
end_at = ${endAt ?? poll.end_at},
|
||||
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
|
||||
updated_at = ${updatedAt}
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${input.voteId}
|
||||
`);
|
||||
|
||||
@@ -609,10 +603,10 @@ export const voteRouter = router({
|
||||
.input(z.object({ voteId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const updatedAt = new Date();
|
||||
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt}
|
||||
SET closed_at = ${gameTime.now},
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${input.voteId}
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CurrentGameTime } from './gameClock.js';
|
||||
import type { GameClockPhase } from '@sammo-ts/common';
|
||||
|
||||
interface ClockFenceRedis {
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
@@ -26,30 +27,58 @@ export interface ActiveRedisClockFence {
|
||||
phaseKey: string;
|
||||
revision: number;
|
||||
generation: number;
|
||||
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
||||
}
|
||||
|
||||
export const ensureActiveRedisClockFence = async (
|
||||
type MutableProjectionPhase = ActiveRedisClockFence['phase'];
|
||||
|
||||
const ensureRedisClockFence = async (
|
||||
redis: ClockFenceRedis,
|
||||
profileName: string,
|
||||
gameTime: CurrentGameTime
|
||||
gameTime: CurrentGameTime,
|
||||
allowedPhases: readonly GameClockPhase[]
|
||||
): Promise<ActiveRedisClockFence | null> => {
|
||||
if (
|
||||
gameTime.phase !== 'RUNNING' ||
|
||||
!gameTime.phase ||
|
||||
!allowedPhases.includes(gameTime.phase) ||
|
||||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
|
||||
!Number.isSafeInteger(gameTime.revision) ||
|
||||
!Number.isSafeInteger(gameTime.deadlineGeneration)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const phase: MutableProjectionPhase = gameTime.phase;
|
||||
const fence: ActiveRedisClockFence = {
|
||||
activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
|
||||
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
|
||||
phaseKey: `sammo:${profileName}:clock:phase`,
|
||||
revision: gameTime.revision!,
|
||||
generation: gameTime.deadlineGeneration!,
|
||||
phase,
|
||||
};
|
||||
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
|
||||
keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey],
|
||||
arguments: [String(fence.revision), String(fence.generation), 'RUNNING'],
|
||||
arguments: [String(fence.revision), String(fence.generation), phase],
|
||||
});
|
||||
return Number(result) === 1 || Number(result) === 2 ? fence : null;
|
||||
};
|
||||
|
||||
export const ensureActiveRedisClockFence = async (
|
||||
redis: ClockFenceRedis,
|
||||
profileName: string,
|
||||
gameTime: CurrentGameTime
|
||||
): Promise<ActiveRedisClockFence | null> => {
|
||||
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
|
||||
};
|
||||
|
||||
/**
|
||||
* User betting is allowed against a frozen tournament deadline while the game
|
||||
* clock is suspended. Stage progression and settlement continue to use the
|
||||
* RUNNING-only helper above.
|
||||
*/
|
||||
export const ensureBettingRedisClockFence = async (
|
||||
redis: ClockFenceRedis,
|
||||
profileName: string,
|
||||
gameTime: CurrentGameTime
|
||||
): Promise<ActiveRedisClockFence | null> =>
|
||||
ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING', 'MANUAL', 'SUSPENDED']);
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
|
||||
|
||||
interface TurnDaemonLeaseSource {
|
||||
turnDaemonLease: {
|
||||
findUnique(input: {
|
||||
where: { profile: string };
|
||||
select: { leaseUntil: true };
|
||||
}): Promise<{ leaseUntil: Date } | null>;
|
||||
};
|
||||
$queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
|
||||
}
|
||||
|
||||
export const loadTurnEngineRunning = async (
|
||||
source: ProfileStatusSource | undefined,
|
||||
db: TurnDaemonLeaseSource,
|
||||
profileName: string,
|
||||
now = new Date()
|
||||
now?: Date
|
||||
): Promise<boolean | null> => {
|
||||
if (!source) return null;
|
||||
try {
|
||||
const status = await source.get(profileName);
|
||||
if (status === null) return null;
|
||||
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
|
||||
const lease = await db.turnDaemonLease.findUnique({
|
||||
where: { profile: profileName },
|
||||
select: { leaseUntil: true },
|
||||
});
|
||||
return lease !== null && lease.leaseUntil.getTime() > now.getTime();
|
||||
const wallNow = now
|
||||
? GamePrisma.sql`${now}`
|
||||
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
|
||||
const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM turn_daemon_lease
|
||||
WHERE profile = ${profileName}
|
||||
AND lease_until > ${wallNow}
|
||||
) AS running
|
||||
`);
|
||||
return rows[0]?.running ?? false;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
|
||||
private readonly db: TurnDaemonLeaseSource,
|
||||
private readonly profileName: string,
|
||||
private readonly cacheMs = 2_000,
|
||||
private readonly now = () => Date.now()
|
||||
private readonly now = () => performance.now()
|
||||
) {}
|
||||
|
||||
get(): Promise<boolean | null> {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
/** Reads the authoritative PostgreSQL UTC wall instant for business rules. */
|
||||
export const readDatabaseWallTime = async (db: Pick<DatabaseClient, '$queryRaw'>): Promise<Date> => {
|
||||
const rows = await db.$queryRaw<Array<{ wallNow: Date }>>(GamePrisma.sql`
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow"
|
||||
`);
|
||||
const wallNow = rows[0]?.wallNow;
|
||||
if (!wallNow) throw new Error('Failed to read PostgreSQL wall time.');
|
||||
return new Date(wallNow);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
|
||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
|
||||
`);
|
||||
if (rows.length === 0) return [];
|
||||
const ids = rows.map((row) => row.id);
|
||||
await tx.webPushOutbox.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
||||
});
|
||||
await tx.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "locked_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"lock_owner" = ${this.owner},
|
||||
"attempts" = "attempts" + 1
|
||||
WHERE "id" IN (${GamePrisma.join(ids)})
|
||||
`);
|
||||
return tx.webPushOutbox.findMany({
|
||||
where: { id: { in: ids }, lockOwner: this.owner },
|
||||
orderBy: { id: 'asc' },
|
||||
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
|
||||
});
|
||||
|
||||
for (const event of claimed) {
|
||||
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
const expired = await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = NULL
|
||||
WHERE "id" = ${event.id}
|
||||
AND "lock_owner" = ${this.owner}
|
||||
AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
- ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond'
|
||||
`);
|
||||
if (expired > 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = NULL
|
||||
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
|
||||
`);
|
||||
} catch (error) {
|
||||
const attempts = event.attempts;
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: {
|
||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
|
||||
},
|
||||
});
|
||||
const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = ${errorText}
|
||||
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
|
||||
`);
|
||||
this.onError(error);
|
||||
}
|
||||
}
|
||||
if (Date.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = Date.now() + 60_000;
|
||||
if (performance.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = performance.now() + 60_000;
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
WITH expired AS (
|
||||
SELECT "id"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -136,7 +137,7 @@ const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T
|
||||
};
|
||||
|
||||
export interface TournamentClockContext {
|
||||
phase: 'RUNNING';
|
||||
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
||||
revision: number;
|
||||
deadlineGeneration: number;
|
||||
dateToTick(date: Date): number | null;
|
||||
@@ -198,8 +199,8 @@ export class TournamentStore {
|
||||
|
||||
const lockKey = `${this.keys.stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (performance.now() < deadline) {
|
||||
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||
if (acquired) {
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => {
|
||||
|
||||
const resolveScheduledBaseMs = (state: TournamentState): number => {
|
||||
const scheduled = new Date(state.nextAt).getTime();
|
||||
return Number.isFinite(scheduled) ? scheduled : Date.now();
|
||||
if (!Number.isFinite(scheduled)) {
|
||||
throw new Error('Tournament GAME_TIME schedule is invalid.');
|
||||
}
|
||||
return scheduled;
|
||||
};
|
||||
|
||||
export const resolveNextAt = (state: TournamentState): string =>
|
||||
|
||||
+71
-52
@@ -62,61 +62,68 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
|
||||
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
|
||||
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
|
||||
|
||||
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||
return next();
|
||||
}
|
||||
const createInputEventMiddleware = (acquireClockFence: boolean) =>
|
||||
t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
|
||||
const payload = await getRawInput();
|
||||
const changeJournal = new ChangeJournal();
|
||||
let journalPersisted = false;
|
||||
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
|
||||
try {
|
||||
const response = await executeInputEvent({
|
||||
db: ctx.db,
|
||||
requestId,
|
||||
eventType: path,
|
||||
payload,
|
||||
actorUserId: ctx.auth?.user.id,
|
||||
execute: async (transaction) => {
|
||||
const result = await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
db: transaction,
|
||||
changeJournal,
|
||||
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
|
||||
executedResult = result;
|
||||
return result.data;
|
||||
},
|
||||
});
|
||||
if (journalPersisted) {
|
||||
ctx.readModelOutbox?.wake();
|
||||
}
|
||||
if (executedResult) {
|
||||
return executedResult;
|
||||
}
|
||||
return {
|
||||
marker: middlewareMarker,
|
||||
ok: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateInputEventError) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: error.message,
|
||||
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
|
||||
const payload = await getRawInput();
|
||||
const changeJournal = new ChangeJournal();
|
||||
let journalPersisted = false;
|
||||
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
|
||||
try {
|
||||
const response = await executeInputEvent({
|
||||
db: ctx.db,
|
||||
requestId,
|
||||
eventType: path,
|
||||
payload,
|
||||
actorUserId: ctx.auth?.user.id,
|
||||
acquireClockFence,
|
||||
execute: async (transaction) => {
|
||||
const result = await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
db: transaction,
|
||||
changeJournal,
|
||||
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
journalPersisted = Boolean(
|
||||
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
||||
);
|
||||
executedResult = result;
|
||||
return result.data;
|
||||
},
|
||||
});
|
||||
if (journalPersisted) {
|
||||
ctx.readModelOutbox?.wake();
|
||||
}
|
||||
if (executedResult) {
|
||||
return executedResult;
|
||||
}
|
||||
return {
|
||||
marker: middlewareMarker,
|
||||
ok: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateInputEventError) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const inputEventMiddleware = createInputEventMiddleware(true);
|
||||
const wallInputEventMiddleware = createInputEventMiddleware(false);
|
||||
|
||||
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
|
||||
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
|
||||
@@ -180,10 +187,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
|
||||
export const authedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(inputEventMiddleware);
|
||||
export const wallAuthedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(wallInputEventMiddleware);
|
||||
|
||||
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
||||
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
||||
@@ -234,6 +246,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
.use(generalAccessEndpointMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(inputEventMiddleware);
|
||||
export const accessWallAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.input(input)
|
||||
.use(generalAccessEndpointMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(wallInputEventMiddleware);
|
||||
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
|
||||
@@ -96,6 +96,7 @@ const buildContext = (options: {
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
closeTick: 200,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -103,6 +104,7 @@ const buildContext = (options: {
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
closeTick: 200,
|
||||
};
|
||||
});
|
||||
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
||||
@@ -112,14 +114,10 @@ const buildContext = (options: {
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
...(options.clockTick === undefined
|
||||
? {}
|
||||
: {
|
||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
}),
|
||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick ?? 100),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
config: {
|
||||
const: {
|
||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||
@@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
tick: 72_000_001,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated auction reads', async () => {
|
||||
@@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => {
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 110,
|
||||
acceptedGameTick: 100,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
});
|
||||
@@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(Date.now() + 60 * 60_000),
|
||||
closeTick: 200n,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => {
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
acceptedGameTick: 100,
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
detail: { amount: 100 },
|
||||
status,
|
||||
closeAt,
|
||||
closeTick: 0n,
|
||||
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
|
||||
},
|
||||
});
|
||||
@@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
return auction;
|
||||
};
|
||||
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
|
||||
buildAuctionFinalizeRequestId(auction.id, {
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
|
||||
if (auction.closeTick === null || auction.closeTick === undefined) {
|
||||
throw new Error(`auction ${auction.id} fixture requires closeTick`);
|
||||
}
|
||||
return buildAuctionFinalizeRequestId(auction.id, {
|
||||
closeAt: auction.closeAt,
|
||||
closeTick: auction.closeTick ?? null,
|
||||
closeTick: auction.closeTick,
|
||||
});
|
||||
};
|
||||
|
||||
const memoryRedis = () => ({
|
||||
zRangeByScore: vi.fn(async () => []),
|
||||
@@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
@@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
|
||||
|
||||
@@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: auction.id },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: auction.id,
|
||||
expectedCloseAt: auction.closeAt.toISOString(),
|
||||
expectedCloseTick: Number(auction.closeTick),
|
||||
},
|
||||
status: 'FAILED',
|
||||
attempts: 3,
|
||||
error: 'simulated terminal failure',
|
||||
@@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
@@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
@@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
|
||||
});
|
||||
|
||||
it('creates a new generation after an earlier close was extended', async () => {
|
||||
const auction = await createAuction('OPEN');
|
||||
const priorRequestId = `auction:finalize:${auction.id}:${auction.closeAt.getTime() - 300_000}`;
|
||||
const priorRequestId = `auction:finalize:${auction.id}:tick:-1`;
|
||||
await connector.prisma.inputEvent.create({
|
||||
data: {
|
||||
requestId: priorRequestId,
|
||||
@@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -431,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
amount: 200,
|
||||
eventId: `auction-durable-bid:${auction.id}`,
|
||||
eventAt: new Date(),
|
||||
occurredGameTick: 0n,
|
||||
requestedAtWall: new Date(),
|
||||
},
|
||||
});
|
||||
const requestId = requestIdFor(auction);
|
||||
@@ -441,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
});
|
||||
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
@@ -509,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
detail: { remainCloseDateExtensionCnt: 1 },
|
||||
status: 'OPEN',
|
||||
closeAt: logicalPastCloseAt,
|
||||
closeTick: 0n,
|
||||
},
|
||||
});
|
||||
extensionAuctionId = extensionAuction.id;
|
||||
@@ -521,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
amount: 50,
|
||||
eventId: `auction-extension-bid:${extensionAuction.id}`,
|
||||
eventAt: new Date(),
|
||||
occurredGameTick: 0n,
|
||||
requestedAtWall: new Date(),
|
||||
meta: { tryExtendCloseDate: true },
|
||||
},
|
||||
});
|
||||
@@ -532,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(extensionAuction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
});
|
||||
|
||||
let reopened: { status: string; closeAt: Date } | null = null;
|
||||
|
||||
@@ -33,7 +33,7 @@ const buildDb = (options: {
|
||||
$executeRaw: vi.fn(async () => options.updated),
|
||||
auction: {
|
||||
findUnique: vi.fn(async () =>
|
||||
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null
|
||||
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? 72_000_000n } : null
|
||||
),
|
||||
},
|
||||
inputEvent: {
|
||||
@@ -233,11 +233,12 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 36_000_000,
|
||||
})
|
||||
).resolves.toBe('RESCHEDULED');
|
||||
|
||||
expect(redis.zAdd).toHaveBeenCalledTimes(1);
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]);
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -267,7 +268,7 @@ describe('auction worker clock-shift race', () => {
|
||||
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||
|
||||
@@ -279,6 +280,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs,
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -293,6 +295,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -324,7 +327,6 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
acceptedGameTick: 72_000_000n,
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
@@ -351,6 +353,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -363,7 +366,7 @@ describe('auction worker clock-shift race', () => {
|
||||
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
@@ -377,6 +380,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
@@ -392,6 +396,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
@@ -412,6 +417,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: logicalNowMs,
|
||||
nowTick: 72_000_000,
|
||||
historyNowMs: operationalNowMs,
|
||||
});
|
||||
|
||||
@@ -421,12 +427,12 @@ describe('auction worker clock-shift race', () => {
|
||||
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const existingEvent = {
|
||||
requestId,
|
||||
target: 'ENGINE' as const,
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
||||
status: 'PENDING' as const,
|
||||
result: null,
|
||||
};
|
||||
@@ -444,6 +450,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -456,7 +463,7 @@ describe('auction worker clock-shift race', () => {
|
||||
it('creates one bounded successor after a terminal event failure', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const retryRequestId = `${requestId}:retry:1`;
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
@@ -466,7 +473,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
||||
status: 'FAILED',
|
||||
result: null,
|
||||
},
|
||||
@@ -481,6 +488,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -494,6 +502,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId: retryRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -501,19 +510,23 @@ describe('auction worker clock-shift race', () => {
|
||||
|
||||
it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
|
||||
const redis = buildRedis();
|
||||
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const closeAt = new Date('2026-07-30T11:30:00.000Z');
|
||||
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const previousRequestId = 'auction:finalize:7:tick:36000000';
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
|
||||
existingEvents: [
|
||||
{
|
||||
requestId: previousRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId: previousRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseTick: 36_000_000,
|
||||
},
|
||||
status: 'SUCCEEDED',
|
||||
result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
|
||||
},
|
||||
@@ -528,6 +541,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -541,6 +555,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -560,6 +575,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).rejects.toThrow('event insert failed');
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
@@ -88,7 +87,7 @@ const storedLetter = {
|
||||
};
|
||||
|
||||
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
|
||||
const create = vi.fn(async () => ({ id: 9 }));
|
||||
const create = vi.fn(async () => ({ id: 9, date: new Date('2026-07-31T00:00:00.000Z') }));
|
||||
let messageId = 100;
|
||||
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
|
||||
const db = {
|
||||
@@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => {
|
||||
textBrief: '<p><strong>공개</strong></p>',
|
||||
textDetail:
|
||||
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
|
||||
date: new Date('0185-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
});
|
||||
expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual(
|
||||
expect.arrayContaining([9002, 'diplomacy', 9001, 9002])
|
||||
);
|
||||
expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy']));
|
||||
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK));
|
||||
expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text')))
|
||||
.toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.');
|
||||
});
|
||||
|
||||
it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
|
||||
|
||||
@@ -326,18 +326,14 @@ describe('general access tracking', () => {
|
||||
: [{ id: 41 }];
|
||||
}),
|
||||
$executeRaw: vi.fn(async (query: unknown) => {
|
||||
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) {
|
||||
const sql = (query as { sql?: string }).sql ?? '';
|
||||
if (sql.includes('INSERT INTO input_event')) {
|
||||
events.push('input-event-create');
|
||||
}
|
||||
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
|
||||
return 1;
|
||||
}),
|
||||
$executeRawUnsafe: vi.fn(async () => 0),
|
||||
inputEvent: {
|
||||
update: vi.fn(async (args: { data: { status: string } }) => {
|
||||
if (args.data.status === 'FAILED') events.push('input-event-failed');
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
};
|
||||
const db = {
|
||||
general: {
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => {
|
||||
it('reuses a rolling-upgrade vote event after legacy acceptance coordinates are removed', async () => {
|
||||
const persistedPayload = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward',
|
||||
@@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
};
|
||||
const currentCommand = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward',
|
||||
userId: 'user-7',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
};
|
||||
const create = async () => {
|
||||
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
|
||||
};
|
||||
@@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
transport.sendCommand(currentCommand)
|
||||
).resolves.toBe('vote-reward');
|
||||
|
||||
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
...currentCommand,
|
||||
...changedIdentity,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
|
||||
}
|
||||
|
||||
@@ -528,10 +528,6 @@ integration('API input event boundary', () => {
|
||||
it('reuses the same engine child event but rejects a changed retry payload', async () => {
|
||||
const transport = new DatabaseTurnDaemonTransport(db, 100);
|
||||
const requestId = 'integration:api:engine-child';
|
||||
const worldClock = await db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockRevision: true, deadlineGeneration: true },
|
||||
});
|
||||
const acceptedWindowStart = Date.now();
|
||||
await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 });
|
||||
const acceptedWindowEnd = Date.now();
|
||||
@@ -539,9 +535,10 @@ integration('API input event boundary', () => {
|
||||
expect(event.actorUserId).toBe('user-7');
|
||||
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
|
||||
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
|
||||
expect(event.acceptedGameTick).not.toBeNull();
|
||||
expect(event.acceptedClockRevision).toBe(worldClock?.clockRevision ?? null);
|
||||
expect(event.acceptedDeadlineGeneration).toBe(worldClock?.deadlineGeneration ?? null);
|
||||
expect(event.acceptedGameTick).toBeNull();
|
||||
expect(event.acceptedClockRevision).toBeNull();
|
||||
expect(event.acceptedDeadlineGeneration).toBeNull();
|
||||
expect(event.processingGameTick).toBeNull();
|
||||
await expect(
|
||||
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
|
||||
).resolves.toBe(requestId);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
|
||||
import { procedure, router } from '../src/trpc.js';
|
||||
import { procedure, router, wallProcedure } from '../src/trpc.js';
|
||||
|
||||
const testRouter = router({
|
||||
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
|
||||
@@ -14,6 +14,14 @@ const testRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
const wallTestRouter = router({
|
||||
mutate: wallProcedure.input(z.object({})).mutation(({ ctx }) => {
|
||||
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
|
||||
ctx.changeJournal?.mark('front.general', 7);
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
const createContext = (payload: unknown = {}) => {
|
||||
const order: string[] = [];
|
||||
const queryRaw = vi.fn(async (query: { sql?: string }) => {
|
||||
@@ -40,7 +48,18 @@ const createContext = (payload: unknown = {}) => {
|
||||
const transaction = {
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: vi.fn(async (query: { sql?: string }) => {
|
||||
order.push(query.sql?.includes('pg_advisory_xact_lock') ? 'clock-fence' : 'accepted');
|
||||
const sql = query.sql ?? '';
|
||||
order.push(
|
||||
sql.includes('pg_advisory_xact_lock')
|
||||
? 'clock-fence'
|
||||
: sql.includes("status = 'PROCESSING'")
|
||||
? 'processing'
|
||||
: sql.includes("status = 'SUCCEEDED'")
|
||||
? 'succeeded'
|
||||
: sql.includes("status = 'FAILED'")
|
||||
? 'failed'
|
||||
: 'accepted'
|
||||
);
|
||||
return 1;
|
||||
}),
|
||||
$executeRawUnsafe: vi.fn(async (statement: string) => {
|
||||
@@ -131,4 +150,24 @@ describe('API input-event change journal boundary', () => {
|
||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||
expect(fixture.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a WALL-only mutation durable without acquiring the GAME clock fence', async () => {
|
||||
const fixture = createContext();
|
||||
|
||||
await expect(wallTestRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.order).toEqual([
|
||||
'transaction-begin',
|
||||
'accepted',
|
||||
'locked',
|
||||
'processing',
|
||||
'savepoint',
|
||||
'handler',
|
||||
'journal',
|
||||
'succeeded',
|
||||
'savepoint-release',
|
||||
'commit',
|
||||
'wake',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ const buildContext = (
|
||||
turnDaemonLease: {
|
||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
|
||||
},
|
||||
$queryRaw: vi.fn(async () => [{ running: true }]),
|
||||
} as unknown as DatabaseClient,
|
||||
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { tombstoneMessages } from '../src/messages/store.js';
|
||||
import { tombstoneMessages, tombstoneMessagesWithinDeleteWindow } from '../src/messages/store.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
@@ -70,6 +70,7 @@ integration('message deletion tombstone persistence', () => {
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
expect(row.validUntil).toEqual(validUntil);
|
||||
expect(row.tombstonedAtWall).not.toBeNull();
|
||||
expect(row.message).toMatchObject({
|
||||
text: '삭제된 메시지입니다.',
|
||||
option: { invalid: true },
|
||||
@@ -81,4 +82,43 @@ integration('message deletion tombstone persistence', () => {
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
|
||||
it('uses the DB wall deadline even when the game clock is not advancing', async () => {
|
||||
const rollback = new Error('rollback wall deletion fixture');
|
||||
await expect(
|
||||
db.$transaction(async (transaction) => {
|
||||
const [{ now_wall: nowWall }] = await transaction.$queryRaw<Array<{ now_wall: Date }>>`
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
|
||||
`;
|
||||
const draft = (text: string) => ({
|
||||
mailbox: 7,
|
||||
type: 'private' as const,
|
||||
src: 7,
|
||||
dest: 8,
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
createdAtWall: nowWall,
|
||||
message: {
|
||||
src: { generalId: 7 },
|
||||
dest: { generalId: 8 },
|
||||
text,
|
||||
option: {},
|
||||
},
|
||||
});
|
||||
const deletable = await transaction.message.create({
|
||||
data: { ...draft('future wall deadline'), deleteUntilWall: new Date(nowWall.getTime() + 60_000) },
|
||||
});
|
||||
const expired = await transaction.message.create({
|
||||
data: { ...draft('past wall deadline'), deleteUntilWall: new Date(nowWall.getTime() - 60_000) },
|
||||
});
|
||||
|
||||
expect(
|
||||
await tombstoneMessagesWithinDeleteWindow(transaction, deletable.id, [deletable.id])
|
||||
).toEqual([deletable.id]);
|
||||
expect(await tombstoneMessagesWithinDeleteWindow(transaction, expired.id, [expired.id])).toEqual([]);
|
||||
|
||||
throw rollback;
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
expect(result.msgType).toBe('national');
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||
expect(queryRaw).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('journals committed message mailbox copies instead of publishing before commit', async () => {
|
||||
@@ -228,6 +228,84 @@ describe('messages router missing-flow compatibility', () => {
|
||||
expect(redis.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)(
|
||||
'keeps ordinary public messages available while the game clock is %s',
|
||||
async (clockPhase) => {
|
||||
const queryRaw = vi.fn(async () => [{ id: 52 }]);
|
||||
const { caller } = buildContext({
|
||||
$queryRaw: queryRaw,
|
||||
worldState: { findFirst: vi.fn(async () => ({ clockPhase })) },
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.messages.send({ generalId: general.id, mailbox: 9999, text: `${clockPhase} 공개 메시지` })
|
||||
).resolves.toMatchObject({ msgType: 'public' });
|
||||
expect(queryRaw).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['SUSPENDED', 'RECONCILING'] as const)(
|
||||
'keeps a received recruitment letter visible with its frozen game deadline while the clock is %s',
|
||||
async (clockPhase) => {
|
||||
const scoutRow = {
|
||||
id: 54,
|
||||
mailbox: general.id,
|
||||
type: 'private',
|
||||
src: 8,
|
||||
dest: general.id,
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
created_at_wall: new Date('2026-09-03T15:00:00.000Z'),
|
||||
action_status: 'PENDING',
|
||||
expires_game_tick: 200n,
|
||||
message: {
|
||||
src: {
|
||||
generalId: 8,
|
||||
generalName: '등용권유자',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
text: '등용 권유 서신',
|
||||
option: { action: 'scout' },
|
||||
},
|
||||
};
|
||||
const { caller } = buildContext({
|
||||
$queryRaw: vi.fn(async () => [scoutRow]),
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockBaseTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
clockTick: 100n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
clockPhase,
|
||||
clockRevision: 9n,
|
||||
deadlineGeneration: 4n,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.messages.getRecent({ generalId: general.id });
|
||||
|
||||
expect(result.private[0]).toMatchObject({
|
||||
id: scoutRow.id,
|
||||
text: '등용 권유 서신',
|
||||
option: { action: 'scout' },
|
||||
time: '2026-09-03 15:00:00',
|
||||
});
|
||||
expect(result.private[0]?.option).not.toMatchObject({ invalid: true });
|
||||
}
|
||||
);
|
||||
|
||||
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
||||
const ambassador = {
|
||||
...general,
|
||||
@@ -259,7 +337,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
expect(result.msgType).toBe('diplomacy');
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => {
|
||||
@@ -288,7 +366,6 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
expect(result.msgType).toBe('diplomacy');
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 9000 },
|
||||
@@ -314,7 +391,6 @@ describe('messages router missing-flow compatibility', () => {
|
||||
|
||||
expect(result.msgType).toBe('national');
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||
});
|
||||
|
||||
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
|
||||
@@ -555,44 +631,50 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
it('invalidates a recent owned message and its receiver copy', async () => {
|
||||
const queryRaw = vi.fn(async () => [
|
||||
{
|
||||
id: 21,
|
||||
mailbox: general.id,
|
||||
type: 'private',
|
||||
src: general.id,
|
||||
dest: 8,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
let rawCall = 0;
|
||||
const queryRaw = vi.fn(async () => {
|
||||
rawCall += 1;
|
||||
if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
|
||||
return [
|
||||
{
|
||||
id: 21,
|
||||
mailbox: general.id,
|
||||
type: 'private',
|
||||
src: general.id,
|
||||
dest: 8,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: 8,
|
||||
generalName: '받는이',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
text: '삭제할 메시지',
|
||||
option: { receiverMessageID: 22 },
|
||||
},
|
||||
dest: {
|
||||
generalId: 8,
|
||||
generalName: '받는이',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
text: '삭제할 메시지',
|
||||
option: { receiverMessageID: 22 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
];
|
||||
});
|
||||
const changeJournal = new ChangeJournal();
|
||||
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
||||
|
||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
|
||||
|
||||
expect(result.deletedIds).toEqual([21, 22]);
|
||||
expect(executeRaw).toHaveBeenCalledOnce();
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
expect(changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 7 },
|
||||
@@ -601,43 +683,49 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
|
||||
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
||||
const queryRaw = vi.fn(async () => [
|
||||
{
|
||||
id: 25,
|
||||
mailbox: 9001,
|
||||
type: 'diplomacy',
|
||||
src: 9001,
|
||||
dest: 9002,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
let rawCall = 0;
|
||||
const queryRaw = vi.fn(async () => {
|
||||
rawCall += 1;
|
||||
if (rawCall > 1) return [{ id: 25 }];
|
||||
return [
|
||||
{
|
||||
id: 25,
|
||||
mailbox: 9001,
|
||||
type: 'diplomacy',
|
||||
src: 9001,
|
||||
dest: 9002,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
text: '일반 외교 메시지',
|
||||
option: { receiverMessageID: 26 },
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
text: '일반 외교 메시지',
|
||||
option: { receiverMessageID: 26 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
];
|
||||
});
|
||||
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
|
||||
|
||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
|
||||
|
||||
expect(result.deletedIds).toEqual([25]);
|
||||
expect(executeRaw).toHaveBeenCalledOnce();
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -864,6 +952,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
const nationUpdate = vi.fn(async () => ({}));
|
||||
const logCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const messageActionUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const cityUpdate = vi.fn(async () => ({}));
|
||||
const changeJournal = new ChangeJournal();
|
||||
const { caller } = buildContext(
|
||||
@@ -941,10 +1030,19 @@ describe('messages router missing-flow compatibility', () => {
|
||||
currentYear: 200,
|
||||
currentMonth: 3,
|
||||
config: { environment: { mapName: 'che' } },
|
||||
clockBaseTime: new Date('0200-03-01T00:00:00.000Z'),
|
||||
clockTick: 1_000n,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-09-03T00:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
})),
|
||||
},
|
||||
logEntry: { createMany: logCreateMany },
|
||||
message: { updateMany: messageUpdateMany },
|
||||
messageAction: { updateMany: messageActionUpdateMany },
|
||||
$queryRaw: queryRaw,
|
||||
},
|
||||
{ changeJournal }
|
||||
@@ -987,7 +1085,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
);
|
||||
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: [31] } },
|
||||
data: { validUntil: expect.any(Date), validUntilTick: 0n },
|
||||
data: { validUntil: expect.any(Date), validUntilTick: 1_000n },
|
||||
});
|
||||
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
|
||||
});
|
||||
|
||||
@@ -13,13 +13,16 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const bettingId = 990_071;
|
||||
const concurrentBettingId = 990_072;
|
||||
const phaseBettingId = 990_073;
|
||||
const generalId = 9_971;
|
||||
const otherGeneralId = 9_972;
|
||||
const phaseGeneralId = 9_973;
|
||||
const nationId = 990_071;
|
||||
const otherNationId = 990_072;
|
||||
const userId = 'nation-betting-router-user';
|
||||
const otherUserId = 'nation-betting-router-other-user';
|
||||
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
||||
const phaseUserId = 'nation-betting-router-phase-user';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
@@ -58,6 +61,17 @@ const noGeneralAuth: GameSessionTokenPayload = {
|
||||
},
|
||||
};
|
||||
|
||||
const phaseAuth: GameSessionTokenPayload = {
|
||||
...auth,
|
||||
sessionId: 'nation-betting-router-phase-session',
|
||||
user: {
|
||||
...auth.user,
|
||||
id: phaseUserId,
|
||||
username: 'phase-bettor',
|
||||
displayName: 'Phase Bettor',
|
||||
},
|
||||
};
|
||||
|
||||
integration('nation betting router', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
@@ -90,12 +104,14 @@ integration('nation betting router', () => {
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inputEvent.deleteMany({
|
||||
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
|
||||
});
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
|
||||
await db.nation.createMany({
|
||||
@@ -138,6 +154,17 @@ integration('nation betting router', () => {
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
id: phaseGeneralId,
|
||||
userId: phaseUserId,
|
||||
name: '정지중베팅장수',
|
||||
nationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
officerLevel: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const world = await db.worldState.create({
|
||||
@@ -169,6 +196,17 @@ integration('nation betting router', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.nationBetting.create({
|
||||
data: {
|
||||
id: phaseBettingId,
|
||||
name: '정지 중 베팅',
|
||||
selectCount: 1,
|
||||
requiresInheritancePoint: true,
|
||||
openYearMonth: 2_400,
|
||||
closeYearMonth: 2_424,
|
||||
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
||||
},
|
||||
});
|
||||
await db.nationBetting.create({
|
||||
data: {
|
||||
id: concurrentBettingId,
|
||||
@@ -184,17 +222,20 @@ integration('nation betting router', () => {
|
||||
data: [
|
||||
{ userId, key: 'previous', value: 1_000 },
|
||||
{ userId: otherUserId, key: 'previous', value: 500 },
|
||||
{ userId: phaseUserId, key: 'previous', value: 500 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inputEvent.deleteMany({
|
||||
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
|
||||
});
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
await db.worldState.delete({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
@@ -290,6 +331,51 @@ integration('nation betting router', () => {
|
||||
).toMatchObject({ value: 250 });
|
||||
});
|
||||
|
||||
it('accepts nation betting during suspension but rejects it during reconciliation', async () => {
|
||||
const before = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
||||
const frozenTick = before.clockTick;
|
||||
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'SUSPENDED' } });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-suspended', phaseAuth)).betting.bet({
|
||||
bettingId: phaseBettingId,
|
||||
bettingType: [0],
|
||||
amount: 100,
|
||||
})
|
||||
).resolves.toEqual({ result: true });
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
|
||||
})
|
||||
).resolves.toMatchObject({ value: 400 });
|
||||
await expect(
|
||||
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
|
||||
).resolves.toMatchObject({ amount: 100 });
|
||||
await expect(db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).resolves.toMatchObject({
|
||||
clockPhase: 'SUSPENDED',
|
||||
clockTick: frozenTick,
|
||||
});
|
||||
|
||||
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RECONCILING' } });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-reconciling', phaseAuth)).betting.bet({
|
||||
bettingId: phaseBettingId,
|
||||
bettingType: [0],
|
||||
amount: 50,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
|
||||
})
|
||||
).resolves.toMatchObject({ value: 400 });
|
||||
await expect(
|
||||
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
|
||||
).resolves.toMatchObject({ amount: 100 });
|
||||
|
||||
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RUNNING' } });
|
||||
});
|
||||
|
||||
it('requires authentication and an owned player general for every betting operation', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
|
||||
|
||||
@@ -420,7 +420,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
});
|
||||
}, 45_000);
|
||||
|
||||
it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => {
|
||||
it('revalidates a queued token at the authoritative daemon processing tick', async () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
|
||||
.join.listPossessCandidates({});
|
||||
@@ -440,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
).rejects.toMatchObject({ code: 'TIMEOUT' });
|
||||
|
||||
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||
const acceptedGameAt = new Date(
|
||||
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time'
|
||||
);
|
||||
expect(acceptedGameAt.toString()).not.toBe('Invalid Date');
|
||||
expect(event.acceptedGameTick).toBeNull();
|
||||
expect(event.processingGameTick).toBeNull();
|
||||
expect(event.payload).not.toHaveProperty('acceptedGameAt');
|
||||
const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!;
|
||||
await db.npcSelectionToken.update({
|
||||
where: { ownerUserId: delayedUserId },
|
||||
data: { validUntil: acceptedGameAt },
|
||||
data: { validUntilTick: queuedAtTick },
|
||||
});
|
||||
await db.worldState.updateMany({
|
||||
data: { clockTick: { increment: 1 } },
|
||||
@@ -470,12 +470,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await startRuntime('npc-possession-delayed-retry-daemon');
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input)
|
||||
).resolves.toEqual({ ok: true, generalId: candidate.id });
|
||||
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '유효한 장수 목록이 없습니다.' });
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
processingGameTick: expect.anything(),
|
||||
});
|
||||
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(1);
|
||||
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(0);
|
||||
}, 45_000);
|
||||
|
||||
it('serializes durable enqueue before a token refresh can replace its nonce', async () => {
|
||||
|
||||
@@ -27,15 +27,16 @@ const payload = (
|
||||
|
||||
const createFixture = (rows: readonly object[]) => {
|
||||
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]);
|
||||
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const executeRaw = vi.fn().mockResolvedValue(1);
|
||||
const incr = vi.fn().mockResolvedValue(41);
|
||||
const publish = vi.fn().mockResolvedValue(1);
|
||||
const db = {
|
||||
$queryRaw: queryRaw,
|
||||
readModelOutbox: { updateMany },
|
||||
$executeRaw: executeRaw,
|
||||
readModelOutbox: {},
|
||||
} as unknown as ReadModelOutboxDatabase;
|
||||
const redis = { incr, publish } as unknown as RedisConnector['client'];
|
||||
return { db, redis, queryRaw, updateMany, incr, publish };
|
||||
return { db, redis, queryRaw, executeRaw, incr, publish };
|
||||
};
|
||||
|
||||
describe('ReadModelOutboxWorker', () => {
|
||||
@@ -47,7 +48,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
|
||||
@@ -64,7 +65,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
|
||||
@@ -74,9 +75,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
revision: 41,
|
||||
changes: { frontStatusActorIds: [7] },
|
||||
});
|
||||
expect(fixture.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
|
||||
);
|
||||
expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"');
|
||||
});
|
||||
|
||||
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
|
||||
@@ -89,7 +88,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(fixture.incr).not.toHaveBeenCalled();
|
||||
@@ -105,7 +104,7 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(fixture.incr).not.toHaveBeenCalled();
|
||||
@@ -150,22 +149,15 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
|
||||
);
|
||||
expect(fixture.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null },
|
||||
data: expect.objectContaining({
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: expect.stringContaining('redis unavailable'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
|
||||
expect(releaseQuery.sql).toContain('"available_at"');
|
||||
expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable'));
|
||||
});
|
||||
|
||||
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
|
||||
|
||||
@@ -710,7 +710,7 @@ describe('appRouter', () => {
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('queues selection-pool reservation with the authenticated actor and server logical time', async () => {
|
||||
it('queues selection-pool reservation without pre-assigning an API game coordinate', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const requestId = 'select-pool-reserve-http';
|
||||
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
|
||||
@@ -741,8 +741,6 @@ describe('appRouter', () => {
|
||||
requestId: commandRequestId,
|
||||
userId: 'user-1',
|
||||
seedOwnerIdentity: 'user-1',
|
||||
acceptedGameAt,
|
||||
acceptedGameTick: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -227,19 +227,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
|
||||
?.some((candidate) => reservedNames.has(candidate.uniqueName))
|
||||
).toBe(false);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
const reserveEvent = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
||||
});
|
||||
expect(reserveEvent).toMatchObject({
|
||||
eventType: 'selectPoolReserve',
|
||||
status: 'SUCCEEDED',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
processingGameTick: expect.anything(),
|
||||
});
|
||||
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameAt');
|
||||
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameTick');
|
||||
|
||||
const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
|
||||
const attempts = await Promise.allSettled([
|
||||
@@ -357,6 +355,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
|
||||
|
||||
const cooledAt = '2026-07-29T00:00:00.000Z';
|
||||
const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
|
||||
await expect(
|
||||
turnDaemon.requestCommand({
|
||||
type: 'patchGeneral',
|
||||
@@ -366,6 +365,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
meta: {
|
||||
next_change: cooledAt,
|
||||
nextChangeAt: cooledAt,
|
||||
next_change_tick: cooledTick,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -380,16 +380,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
.createCaller(buildContext('select-pool-reselect'))
|
||||
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
|
||||
).resolves.toEqual({ ok: true, generalId: initial.id });
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } })
|
||||
).resolves.toMatchObject({
|
||||
const reselectionEvent = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' },
|
||||
});
|
||||
expect(reselectionEvent).toMatchObject({
|
||||
eventType: 'selectPoolReselect',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
processingGameTick: expect.anything(),
|
||||
});
|
||||
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt');
|
||||
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick');
|
||||
|
||||
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
|
||||
expect(updated).toMatchObject({
|
||||
@@ -455,6 +455,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
|
||||
});
|
||||
const secondCooledAt = '2026-07-28T00:00:00.000Z';
|
||||
const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
|
||||
await turnDaemon.requestCommand({
|
||||
type: 'patchGeneral',
|
||||
requestId: 'select-pool-full-cooldown-patch',
|
||||
@@ -463,6 +464,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
meta: {
|
||||
next_change: secondCooledAt,
|
||||
nextChangeAt: secondCooledAt,
|
||||
next_change_tick: secondCooledTick,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -571,21 +573,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
.join.selectPoolGeneral(stableInput);
|
||||
expect(retried).toEqual(first);
|
||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({
|
||||
where: {
|
||||
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
|
||||
},
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
const stableEvent = await db.inputEvent.findUniqueOrThrow({
|
||||
where: {
|
||||
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
|
||||
},
|
||||
});
|
||||
expect(stableEvent).toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: otherUserId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
processingGameTick: expect.anything(),
|
||||
});
|
||||
expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt');
|
||||
expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick');
|
||||
}, 30_000);
|
||||
|
||||
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
@@ -151,6 +151,8 @@ const buildContext = (options: {
|
||||
develCost?: number;
|
||||
currentDevelCost?: number;
|
||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
|
||||
requestId?: string;
|
||||
}): GameApiContext => {
|
||||
const db = {
|
||||
general: {
|
||||
@@ -168,7 +170,7 @@ const buildContext = (options: {
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
clockPhase: 'RUNNING',
|
||||
clockPhase: options.clockPhase ?? 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
tickSeconds: 60,
|
||||
@@ -178,6 +180,7 @@ const buildContext = (options: {
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
return {
|
||||
requestId: options.requestId,
|
||||
db,
|
||||
redis: options.redis as unknown as RedisConnector['client'],
|
||||
turnDaemon: options.transport,
|
||||
@@ -369,6 +372,83 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
||||
});
|
||||
|
||||
it('accepts a tournament bet against the frozen game deadline while the clock is suspended', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 3_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
const context = buildContext({
|
||||
redis,
|
||||
transport,
|
||||
generals: [general],
|
||||
userId: 'user-1',
|
||||
clockPhase: 'SUSPENDED',
|
||||
requestId: 'http:suspended-tournament-bet',
|
||||
});
|
||||
const outerApiTransaction = vi.fn(async () => {
|
||||
throw new Error('tournament bet must not hold an API transaction while waiting for the daemon');
|
||||
});
|
||||
Object.assign(context.db, { $transaction: outerApiTransaction });
|
||||
const caller = appRouter.createCaller(context);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).resolves.toEqual({ ok: true });
|
||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
||||
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
||||
reason: 'tournamentBet',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
|
||||
reason: 'tournamentBet',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 3_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-1', clockPhase: 'RECONCILING' })
|
||||
);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
});
|
||||
expect(transport.gold.get(general.id)).toBe(3_000);
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
|
||||
@@ -5,10 +5,8 @@ import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/t
|
||||
describe('turn engine status projection', () => {
|
||||
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
|
||||
const activeLease = {
|
||||
turnDaemonLease: {
|
||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })),
|
||||
},
|
||||
};
|
||||
$queryRaw: vi.fn(async () => [{ running: true }]),
|
||||
} as any;
|
||||
const now = new Date('2026-08-24T00:00:00.000Z');
|
||||
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
|
||||
true
|
||||
@@ -36,7 +34,7 @@ describe('turn engine status projection', () => {
|
||||
await expect(
|
||||
loadTurnEngineRunning(
|
||||
source,
|
||||
{ turnDaemonLease: { findUnique: async () => null } },
|
||||
{ $queryRaw: async () => [{ running: false }] } as any,
|
||||
'che:default',
|
||||
now
|
||||
)
|
||||
@@ -44,11 +42,7 @@ describe('turn engine status projection', () => {
|
||||
await expect(
|
||||
loadTurnEngineRunning(
|
||||
source,
|
||||
{
|
||||
turnDaemonLease: {
|
||||
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
|
||||
},
|
||||
},
|
||||
{ $queryRaw: async () => [{ running: false }] } as any,
|
||||
'che:default',
|
||||
now
|
||||
)
|
||||
@@ -58,10 +52,10 @@ describe('turn engine status projection', () => {
|
||||
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
|
||||
let now = 1_000;
|
||||
const get = vi.fn(async () => 'RUNNING' as const);
|
||||
const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') }));
|
||||
const queryRaw = vi.fn(async () => [{ running: true }]);
|
||||
const cache = new CachedTurnEngineStatus(
|
||||
{ get },
|
||||
{ turnDaemonLease: { findUnique } },
|
||||
{ $queryRaw: queryRaw } as any,
|
||||
'che:default',
|
||||
2_000,
|
||||
() => now
|
||||
@@ -75,6 +69,6 @@ describe('turn engine status projection', () => {
|
||||
now += 1;
|
||||
await expect(cache.get()).resolves.toBe(true);
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(findUnique).toHaveBeenCalledTimes(2);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,13 +230,7 @@ describe('vote router actor and permission boundaries', () => {
|
||||
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false);
|
||||
expect(
|
||||
hasPollEnded(
|
||||
{ closed_at: null, end_at: now, end_tick: null },
|
||||
{ ...time, now: new Date(now.getTime() + 1), tick: null }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated survey access', async () => {
|
||||
@@ -261,7 +255,6 @@ describe('vote router actor and permission boundaries', () => {
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
});
|
||||
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
|
||||
false
|
||||
@@ -301,8 +294,6 @@ describe('vote router actor and permission boundaries', () => {
|
||||
const auth = buildAuth(['admin.survey.open']);
|
||||
const fixture = buildContext({ auth });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const windowStart = Date.now();
|
||||
|
||||
await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
caller.vote.createPoll({
|
||||
@@ -314,8 +305,6 @@ describe('vote router actor and permission boundaries', () => {
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true });
|
||||
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
|
||||
const windowEnd = Date.now();
|
||||
|
||||
const mutationQueries = fixture.queryRaw.mock.calls
|
||||
.map(([query]) => query)
|
||||
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query)));
|
||||
@@ -325,30 +314,24 @@ describe('vote router actor and permission boundaries', () => {
|
||||
const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
|
||||
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
|
||||
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
|
||||
const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => {
|
||||
const expectDbWallClock = (query: GamePrisma.Sql | undefined): void => {
|
||||
expect(query).toBeDefined();
|
||||
const value = query?.values.at(index);
|
||||
expect(value).toBeInstanceOf(Date);
|
||||
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
|
||||
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
|
||||
return value as Date;
|
||||
expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
|
||||
};
|
||||
|
||||
expect(sqlText(commentInsert!)).toContain('created_at');
|
||||
expectCurrentDateAt(commentInsert, -1);
|
||||
expectDbWallClock(commentInsert);
|
||||
expect(sqlText(pollInsert!)).toContain('created_at');
|
||||
expect(sqlText(pollInsert!)).toContain('updated_at');
|
||||
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2);
|
||||
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
|
||||
expect(pollUpdatedAt).toBe(pollCreatedAt);
|
||||
expectDbWallClock(pollInsert);
|
||||
|
||||
expect(pollUpdates).toHaveLength(3);
|
||||
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
|
||||
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt);
|
||||
expectDbWallClock(closePreviousUpdate);
|
||||
expect(sqlText(editPollUpdate!)).toContain('updated_at');
|
||||
expectCurrentDateAt(editPollUpdate, -2);
|
||||
expectDbWallClock(editPollUpdate);
|
||||
expect(sqlText(closePollUpdate!)).toContain('updated_at');
|
||||
expectCurrentDateAt(closePollUpdate, -2);
|
||||
expectDbWallClock(closePollUpdate);
|
||||
});
|
||||
|
||||
it('reports the current world develcost as the legacy five-times survey reward', async () => {
|
||||
|
||||
Reference in New Issue
Block a user