시간 작동 방식 변경 #1
@@ -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 () => {
|
||||
|
||||
@@ -60,27 +60,27 @@ export const hasAuctionClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
now: Date,
|
||||
nowTick: number | null
|
||||
): boolean =>
|
||||
auction.closeTick !== null && nowTick !== null
|
||||
? auction.closeTick < BigInt(nowTick)
|
||||
: auction.closeAt.getTime() < now.getTime();
|
||||
): boolean => {
|
||||
void now;
|
||||
return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
|
||||
};
|
||||
|
||||
export const resolveAuctionBidTiming = (
|
||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||
processingNow: Date,
|
||||
acceptedGameTick?: number
|
||||
): { bidAt: Date; bidTick: number } =>
|
||||
acceptedGameTick === undefined
|
||||
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) }
|
||||
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick };
|
||||
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
|
||||
processingGameTick: number
|
||||
): { bidAt: Date; bidTick: number } => {
|
||||
if (!Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('Auction bid requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
|
||||
};
|
||||
|
||||
export const hasAuctionBidClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||
processingNow: Date,
|
||||
acceptedGameTick?: number
|
||||
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
|
||||
processingGameTick: number
|
||||
): boolean => {
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick);
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
|
||||
return hasAuctionClosePassed(auction, bidAt, bidTick);
|
||||
};
|
||||
|
||||
@@ -268,15 +268,15 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '경매가 종료되었습니다.',
|
||||
};
|
||||
}
|
||||
const processingNow = world.getGameNow(new Date());
|
||||
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(
|
||||
world,
|
||||
processingNow,
|
||||
typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick)
|
||||
? convertedProcessingTick
|
||||
: command.acceptedGameTick
|
||||
);
|
||||
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
|
||||
throw new Error('auctionBid requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
|
||||
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
|
||||
throw new Error('auctionBid requires its durable input-event wall occurrence.');
|
||||
}
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick);
|
||||
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
@@ -510,13 +510,24 @@ export const createAuctionBidder = async (options: {
|
||||
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
|
||||
await tx.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
|
||||
INSERT INTO auction_bid (
|
||||
auction_id,
|
||||
general_id,
|
||||
amount,
|
||||
event_id,
|
||||
event_at,
|
||||
occurred_game_tick,
|
||||
requested_at_wall,
|
||||
meta
|
||||
)
|
||||
VALUES (
|
||||
${command.auctionId},
|
||||
${command.generalId},
|
||||
${command.amount},
|
||||
${eventId},
|
||||
${eventAt},
|
||||
${BigInt(bidTick)},
|
||||
${requestedAtWall},
|
||||
${JSON.stringify({
|
||||
tryExtendCloseDate: command.tryExtendCloseDate ?? true,
|
||||
...(auction.type === 'UNIQUE_ITEM'
|
||||
@@ -536,7 +547,7 @@ export const createAuctionBidder = async (options: {
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
latest_event_id = ${eventId},
|
||||
latest_event_at = ${eventAt},
|
||||
updated_at = ${eventAt}
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${command.auctionId}
|
||||
AND status = 'OPEN'
|
||||
AND latest_event_id = ${auction.latestEventId}
|
||||
@@ -556,7 +567,7 @@ export const createAuctionBidder = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE inheritance_point
|
||||
SET value = value - ${morePoint},
|
||||
updated_at = ${eventAt}
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE user_id = ${userId}
|
||||
AND key = 'previous'
|
||||
AND value >= ${morePoint}
|
||||
@@ -587,11 +598,16 @@ export const createAuctionBidder = async (options: {
|
||||
await tx.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
||||
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt})
|
||||
VALUES (
|
||||
${prevUserId},
|
||||
'previous',
|
||||
${highestBid.amount},
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
ON CONFLICT (user_id, key)
|
||||
DO UPDATE SET
|
||||
value = inheritance_point.value + EXCLUDED.value,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
`
|
||||
);
|
||||
await tx.$executeRaw(
|
||||
@@ -711,6 +727,7 @@ export const createAuctionBidder = async (options: {
|
||||
ok: true,
|
||||
auctionId: command.auctionId,
|
||||
closeAt: nextCloseAt.toISOString(),
|
||||
closeTick: world.dateToGameTick(nextCloseAt),
|
||||
};
|
||||
},
|
||||
close: async (): Promise<void> => {
|
||||
|
||||
@@ -91,21 +91,17 @@ export const isAuctionFinalizeGenerationCurrent = (
|
||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
|
||||
): boolean => {
|
||||
if (command.expectedCloseTick !== undefined) {
|
||||
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
||||
}
|
||||
if (command.expectedCloseAt !== undefined) {
|
||||
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
|
||||
}
|
||||
return true;
|
||||
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
||||
};
|
||||
|
||||
export const hasAuctionFinalizeDeadlineArrived = (
|
||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): boolean =>
|
||||
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick);
|
||||
): boolean => {
|
||||
void now;
|
||||
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
|
||||
};
|
||||
|
||||
export const buildAuctionBidderSystemMessage = (options: {
|
||||
bidder: TurnGeneral;
|
||||
@@ -293,7 +289,11 @@ export const createAuctionFinalizer = async (options: {
|
||||
return { type: 'auctionFinalize', ok: true, auctionId };
|
||||
}
|
||||
|
||||
const now = world.getGameNow(new Date());
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('auctionFinalize requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const now = world.gameTickToDate(processingGameTick);
|
||||
if (auction.status === 'OPEN') {
|
||||
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
|
||||
return {
|
||||
@@ -303,8 +303,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
reason: '경매 마감 세대가 변경되었습니다.',
|
||||
};
|
||||
}
|
||||
const nowTick = world.dateToGameTick(now);
|
||||
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
|
||||
if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -316,8 +315,8 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'FINALIZING',
|
||||
finalizing_at = ${now},
|
||||
updated_at = ${now}
|
||||
finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${auctionId}
|
||||
AND status = 'OPEN'
|
||||
`
|
||||
@@ -364,8 +363,8 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = ${status},
|
||||
finished_at = ${now},
|
||||
updated_at = ${now}
|
||||
finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${auctionId}
|
||||
`
|
||||
);
|
||||
|
||||
@@ -94,7 +94,11 @@ const openResourceAuction = async (
|
||||
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
|
||||
}
|
||||
|
||||
const now = world.getGameNow(new Date());
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const now = world.gameTickToDate(processingGameTick);
|
||||
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
|
||||
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
|
||||
const auction = await db.auction.create({
|
||||
@@ -113,7 +117,7 @@ const openResourceAuction = async (
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
openTick: BigInt(world.dateToGameTick(now)),
|
||||
openTick: BigInt(processingGameTick),
|
||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||
},
|
||||
});
|
||||
@@ -125,6 +129,7 @@ const openResourceAuction = async (
|
||||
ok: true,
|
||||
auctionId: auction.id,
|
||||
closeAt: closeAt.toISOString(),
|
||||
closeTick: world.dateToGameTick(closeAt),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -220,8 +225,16 @@ const openUniqueAuction = async (
|
||||
}
|
||||
|
||||
const state = world.getState();
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
|
||||
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
|
||||
throw new Error('auctionOpen requires its durable input-event wall occurrence.');
|
||||
}
|
||||
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
|
||||
const now = world.getGameNow(new Date());
|
||||
const now = world.gameTickToDate(processingGameTick);
|
||||
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
|
||||
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
|
||||
const extensionLimitMinutes = Math.max(
|
||||
@@ -253,7 +266,7 @@ const openUniqueAuction = async (
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
openTick: BigInt(world.dateToGameTick(now)),
|
||||
openTick: BigInt(processingGameTick),
|
||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||
latestEventId: eventId,
|
||||
latestEventAt: now,
|
||||
@@ -263,6 +276,8 @@ const openUniqueAuction = async (
|
||||
amount: command.amount,
|
||||
eventId,
|
||||
eventAt: now,
|
||||
occurredGameTick: BigInt(processingGameTick),
|
||||
requestedAtWall,
|
||||
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
|
||||
},
|
||||
},
|
||||
@@ -308,6 +323,7 @@ const openUniqueAuction = async (
|
||||
ok: true,
|
||||
auctionId: auction.id,
|
||||
closeAt: closeAt.toISOString(),
|
||||
closeTick: world.dateToGameTick(closeAt),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
|
||||
import type {
|
||||
@@ -10,8 +11,9 @@ import type {
|
||||
TurnDaemonStatus,
|
||||
} from './types.js';
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const serializeResult = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, item: unknown) => (typeof item === 'bigint' ? item.toString() : item)) ?? 'null';
|
||||
|
||||
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
|
||||
private readonly localQueue: TurnDaemonCommand[] = [];
|
||||
@@ -35,8 +37,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
return local.concat(remote);
|
||||
}
|
||||
|
||||
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
while (deadlineMs === null || Date.now() < deadlineMs) {
|
||||
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs);
|
||||
while (deadline === null || performance.now() < deadline) {
|
||||
const local = this.localQueue.shift();
|
||||
if (local) {
|
||||
return local;
|
||||
@@ -45,7 +48,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
if (remote[0]) {
|
||||
return remote[0];
|
||||
}
|
||||
const remaining = deadlineMs === null ? 100 : Math.max(1, Math.min(100, deadlineMs - Date.now()));
|
||||
const remaining = deadline === null ? 100 : Math.max(1, Math.min(100, deadline - performance.now()));
|
||||
await delay(remaining);
|
||||
}
|
||||
return null;
|
||||
@@ -84,35 +87,37 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
return;
|
||||
}
|
||||
const terminal = event.attempts >= this.maxAttempts;
|
||||
await transaction.inputEvent.updateMany({
|
||||
where: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
status: 'PROCESSING',
|
||||
lockedBy: this.workerId,
|
||||
attempts: event.attempts,
|
||||
},
|
||||
data: {
|
||||
status: terminal ? 'FAILED' : 'PENDING',
|
||||
processingAt: null,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
completedAt: terminal ? new Date() : null,
|
||||
result: GamePrisma.DbNull,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus",
|
||||
processing_at = NULL,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL,
|
||||
completed_at = CASE
|
||||
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
ELSE NULL
|
||||
END,
|
||||
result = NULL,
|
||||
error = ${message}
|
||||
WHERE request_id = ${requestId}
|
||||
AND target = 'ENGINE'::"InputEventTarget"
|
||||
AND status = 'PROCESSING'::"InputEventStatus"
|
||||
AND locked_by = ${this.workerId}
|
||||
AND attempts = ${event.attempts}
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
|
||||
await this.recoverExpiredLeases();
|
||||
return this.db.$transaction(async (transaction) => {
|
||||
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
||||
const world = await transaction.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
|
||||
});
|
||||
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
|
||||
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
|
||||
const currentRevision = world?.clockRevision ?? null;
|
||||
const rows = await transaction.$queryRaw<
|
||||
Array<{
|
||||
@@ -141,6 +146,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
AND (
|
||||
${gameplayAllowed}
|
||||
OR "event_type" = 'getStatus'
|
||||
OR (
|
||||
${suspendedTournamentBetCommand}
|
||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
||||
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
||||
)
|
||||
OR (
|
||||
${world?.clockPhase === 'SUSPENDED'}
|
||||
AND "event_type" = 'messageRespond'
|
||||
@@ -148,8 +158,14 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "message" AS pending_message
|
||||
JOIN "message_action" AS pending_action
|
||||
ON pending_action."message_id" = pending_message."id"
|
||||
WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer
|
||||
AND pending_message."message" #>> '{option,action}' = 'raiseInvader'
|
||||
AND pending_action."action_type" = 'raiseInvader'
|
||||
AND pending_action."status" = 'PENDING'
|
||||
AND pending_action."clock_revision" = ${currentRevision}
|
||||
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
@@ -175,9 +191,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
})
|
||||
: [];
|
||||
const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => {
|
||||
if (row.eventType === 'getStatus') return row.acceptedGameTick;
|
||||
if (row.eventType === 'getStatus') return row.acceptedGameTick ?? claimCoordinate.gameTick;
|
||||
if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) {
|
||||
return row.acceptedGameTick ?? world?.clockTick ?? null;
|
||||
return claimCoordinate.gameTick;
|
||||
}
|
||||
if (row.acceptedClockRevision > currentRevision) return undefined;
|
||||
let revision = row.acceptedClockRevision;
|
||||
@@ -197,19 +213,25 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
entry.processingGameTick !== undefined
|
||||
);
|
||||
for (const { row, processingGameTick } of processableRows) {
|
||||
await transaction.inputEvent.update({
|
||||
where: { sequence: row.sequence },
|
||||
data: {
|
||||
status: 'PROCESSING',
|
||||
processingAt: new Date(),
|
||||
processingGameTick,
|
||||
processingClockRevision: currentRevision,
|
||||
processingDeadlineGeneration: world?.deadlineGeneration ?? null,
|
||||
lockedBy: this.workerId,
|
||||
leaseUntil: new Date(Date.now() + this.leaseDurationMs),
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'PROCESSING'::"InputEventStatus",
|
||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
accepted_game_tick = COALESCE(accepted_game_tick, ${processingGameTick}),
|
||||
accepted_clock_revision = COALESCE(accepted_clock_revision, ${currentRevision}),
|
||||
accepted_deadline_generation = COALESCE(
|
||||
accepted_deadline_generation,
|
||||
${world?.deadlineGeneration ?? null}
|
||||
),
|
||||
processing_game_tick = ${processingGameTick},
|
||||
processing_clock_revision = ${currentRevision},
|
||||
processing_deadline_generation = ${world?.deadlineGeneration ?? null},
|
||||
locked_by = ${this.workerId},
|
||||
lease_until = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
+ ${this.leaseDurationMs} * INTERVAL '1 millisecond',
|
||||
attempts = attempts + 1
|
||||
WHERE sequence = ${row.sequence}
|
||||
`);
|
||||
}
|
||||
|
||||
const commands: TurnDaemonCommand[] = [];
|
||||
@@ -220,39 +242,34 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
command: row.payload as TurnDaemonCommand,
|
||||
});
|
||||
if (!command) {
|
||||
await transaction.inputEvent.update({
|
||||
where: { sequence: row.sequence },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
error: `Invalid command payload for ${row.eventType}`,
|
||||
completedAt: new Date(),
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'FAILED'::"InputEventStatus",
|
||||
error = ${`Invalid command payload for ${row.eventType}`},
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
locked_by = NULL,
|
||||
lease_until = NULL
|
||||
WHERE sequence = ${row.sequence}
|
||||
`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
processingGameTick !== null &&
|
||||
row.acceptedGameTick !== null &&
|
||||
processingGameTick !== row.acceptedGameTick
|
||||
) {
|
||||
if (processingGameTick !== null) {
|
||||
const value = Number(processingGameTick);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
await transaction.inputEvent.update({
|
||||
where: { sequence: row.sequence },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
error: 'Converted processing game tick is outside the safe integer range.',
|
||||
completedAt: new Date(),
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'FAILED'::"InputEventStatus",
|
||||
error = 'Converted processing game tick is outside the safe integer range.',
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
locked_by = NULL,
|
||||
lease_until = NULL
|
||||
WHERE sequence = ${row.sequence}
|
||||
`);
|
||||
continue;
|
||||
}
|
||||
Reflect.set(command, 'processingGameTick', value);
|
||||
}
|
||||
Reflect.set(command, 'requestedAtWall', row.createdAt);
|
||||
commands.push(command);
|
||||
}
|
||||
return commands;
|
||||
@@ -260,23 +277,20 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
}
|
||||
|
||||
private async complete(requestId: string, result: unknown): Promise<void> {
|
||||
const completed = await this.db.inputEvent.updateMany({
|
||||
where: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
status: 'PROCESSING',
|
||||
lockedBy: this.workerId,
|
||||
},
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
result: asJson(result),
|
||||
completedAt: new Date(),
|
||||
error: null,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
if (completed.count > 0) {
|
||||
const completed = await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'SUCCEEDED'::"InputEventStatus",
|
||||
result = CAST(${serializeResult(result)} AS jsonb),
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
error = NULL,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL
|
||||
WHERE request_id = ${requestId}
|
||||
AND target = 'ENGINE'::"InputEventTarget"
|
||||
AND status = 'PROCESSING'::"InputEventStatus"
|
||||
AND locked_by = ${this.workerId}
|
||||
`);
|
||||
if (completed > 0) {
|
||||
return;
|
||||
}
|
||||
// Database hooks commit mutation results atomically with game state and
|
||||
@@ -297,19 +311,15 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
||||
}
|
||||
|
||||
private async recoverExpiredLeases(): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.db.inputEvent.updateMany({
|
||||
where: {
|
||||
target: 'ENGINE',
|
||||
status: 'PROCESSING',
|
||||
leaseUntil: { lt: now },
|
||||
},
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
processingAt: null,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'PENDING'::"InputEventStatus",
|
||||
processing_at = NULL,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL
|
||||
WHERE target = 'ENGINE'::"InputEventTarget"
|
||||
AND status = 'PROCESSING'::"InputEventStatus"
|
||||
AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
|
||||
|
||||
type Waiter = {
|
||||
deadlineMs: number | null;
|
||||
timeoutMs: number | null;
|
||||
resolve: (command: TurnDaemonCommand | null) => void;
|
||||
timeoutId?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
@@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue {
|
||||
return drained;
|
||||
}
|
||||
|
||||
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
if (this.queue.length > 0) {
|
||||
return this.queue.shift() ?? null;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const waiter: Waiter = { deadlineMs, resolve };
|
||||
if (deadlineMs !== null) {
|
||||
const delay = Math.max(0, deadlineMs - Date.now());
|
||||
const waiter: Waiter = { timeoutMs, resolve };
|
||||
if (timeoutMs !== null) {
|
||||
const delay = Math.max(0, timeoutMs);
|
||||
waiter.timeoutId = setTimeout(() => {
|
||||
this.removeWaiter(waiter);
|
||||
resolve(null);
|
||||
|
||||
@@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur
|
||||
return drained.concat(remote);
|
||||
}
|
||||
|
||||
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
if (this.localQueue.length > 0) {
|
||||
return this.localQueue.shift() ?? null;
|
||||
}
|
||||
|
||||
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now());
|
||||
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000);
|
||||
if (deadlineMs !== null && blockMs === 0) {
|
||||
const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs);
|
||||
const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000);
|
||||
if (timeoutMs !== null && blockMs === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ export class TurnDaemonLifecycle {
|
||||
continue;
|
||||
}
|
||||
|
||||
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs));
|
||||
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
|
||||
if (command) {
|
||||
await this.handleCommand(command);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export class TurnDaemonLifecycle {
|
||||
}
|
||||
|
||||
private async waitForResume(): Promise<void> {
|
||||
const command = await this.controlQueue.waitUntil(null);
|
||||
const command = await this.controlQueue.waitFor(null);
|
||||
if (command) {
|
||||
await this.handleCommand(command);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export interface TurnStateStore {
|
||||
export interface TurnDaemonControlQueue {
|
||||
enqueue(command: TurnDaemonCommand): void;
|
||||
drain(): Promise<TurnDaemonCommand[]>;
|
||||
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>;
|
||||
waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null>;
|
||||
getDepth(): number;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ interface MessageRow {
|
||||
type: string;
|
||||
time: Date;
|
||||
validUntil: Date;
|
||||
actionType: string;
|
||||
actionStatus: string;
|
||||
createdGameTick: bigint;
|
||||
expiresGameTick: bigint | null;
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
@@ -98,11 +102,16 @@ const invalidateMessageIds = async (
|
||||
): Promise<void> => {
|
||||
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const resolvedGameTick = BigInt(world.dateToGameTick(now));
|
||||
await db.messageAction.updateMany({
|
||||
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick },
|
||||
});
|
||||
await db.message.updateMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
data: {
|
||||
validUntil: now,
|
||||
validUntilTick: BigInt(world.dateToGameTick(now)),
|
||||
validUntilTick: resolvedGameTick,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -113,40 +122,53 @@ const validateActor = async (options: {
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
}): Promise<Date> => {
|
||||
}): Promise<{ processingGameTick: number }> => {
|
||||
const actor = options.world.getGeneralById(options.generalId);
|
||||
if (!actor || actor.userId !== options.userId) {
|
||||
throw new Error('messageRespond general owner does not match command user.');
|
||||
}
|
||||
if (!options.requestId) return new Date();
|
||||
if (!options.requestId) {
|
||||
throw new Error('messageRespond requires a durable ENGINE input event requestId.');
|
||||
}
|
||||
const event = await options.db.inputEvent.findUnique({
|
||||
where: { requestId: options.requestId },
|
||||
select: { actorUserId: true, target: true, eventType: true, createdAt: true },
|
||||
select: { actorUserId: true, target: true, eventType: true, processingGameTick: true },
|
||||
});
|
||||
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
|
||||
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
|
||||
throw new Error('ENGINE input event actor or type does not match messageRespond.');
|
||||
}
|
||||
return event.createdAt;
|
||||
const processingGameTick = event.processingGameTick;
|
||||
if (processingGameTick === null || !Number.isSafeInteger(Number(processingGameTick))) {
|
||||
throw new Error('messageRespond requires an authoritative processing game tick.');
|
||||
}
|
||||
return { processingGameTick: Number(processingGameTick) };
|
||||
};
|
||||
|
||||
const fetchMessageForUpdate = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld,
|
||||
messageId: number,
|
||||
now: Date
|
||||
currentGameTick: number
|
||||
): Promise<MessageRow | null> => {
|
||||
const currentTick = BigInt(world.dateToGameTick(now));
|
||||
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
|
||||
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
|
||||
FROM message
|
||||
WHERE id = ${messageId}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||
)
|
||||
SELECT
|
||||
envelope.id,
|
||||
envelope.mailbox,
|
||||
envelope.type,
|
||||
envelope.time,
|
||||
envelope.valid_until AS "validUntil",
|
||||
action.action_type AS "actionType",
|
||||
action.status AS "actionStatus",
|
||||
action.created_game_tick AS "createdGameTick",
|
||||
action.expires_game_tick AS "expiresGameTick",
|
||||
envelope.message
|
||||
FROM message AS envelope
|
||||
JOIN message_action AS action ON action.message_id = envelope.id
|
||||
WHERE envelope.id = ${messageId}
|
||||
AND action.status = 'PENDING'
|
||||
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(currentGameTick)})
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
FOR UPDATE OF envelope, action
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
@@ -165,7 +187,7 @@ const respondToScout = async (options: {
|
||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
|
||||
}
|
||||
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) {
|
||||
if (row.actionStatus !== 'PENDING' || row.actionType !== 'scout' || isLegacyTruthy(asRecord(payload.option).used)) {
|
||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
||||
}
|
||||
|
||||
@@ -186,18 +208,17 @@ const respondToScout = async (options: {
|
||||
}
|
||||
|
||||
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
SELECT id
|
||||
FROM message
|
||||
WHERE mailbox = ${payload.src.generalId}
|
||||
AND type = 'private'
|
||||
AND dest = mailbox
|
||||
AND id <> ${row.id}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||
)
|
||||
AND message->'option'->>'action' = 'scout'
|
||||
FOR UPDATE
|
||||
SELECT envelope.id
|
||||
FROM message AS envelope
|
||||
JOIN message_action AS action ON action.message_id = envelope.id
|
||||
WHERE envelope.mailbox = ${payload.src.generalId}
|
||||
AND envelope.type = 'private'
|
||||
AND envelope.dest = envelope.mailbox
|
||||
AND envelope.id <> ${row.id}
|
||||
AND action.status = 'PENDING'
|
||||
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
|
||||
AND action.action_type = 'scout'
|
||||
FOR UPDATE OF envelope, action
|
||||
`);
|
||||
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
|
||||
world.queueMessage({
|
||||
@@ -327,6 +348,7 @@ const respondToRaiseInvader = async (options: {
|
||||
},
|
||||
event
|
||||
);
|
||||
await invalidateMessageIds(db, world, [row.id], world.gameTickToDate(alignment.alignedTick));
|
||||
return { ok: true, action: 'raiseInvader', reason: 'success' };
|
||||
};
|
||||
|
||||
@@ -354,13 +376,14 @@ export const respondToActionableMessage = async (options: {
|
||||
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
||||
}) => Promise<ClockReconciliationResult>;
|
||||
}): Promise<ActionableMessageResponseResult> => {
|
||||
const acceptedAt = await validateActor(options);
|
||||
const now = options.world.getGameNow(acceptedAt);
|
||||
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
|
||||
const accepted = await validateActor(options);
|
||||
const now = options.world.gameTickToDate(accepted.processingGameTick);
|
||||
const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
|
||||
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
||||
const payload = parsePayload(row.message);
|
||||
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
||||
const action = asRecord(payload.option).action;
|
||||
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
|
||||
if (action === 'scout') {
|
||||
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ const lockWorld = async (db: GamePrisma.TransactionClient): Promise<number> => {
|
||||
return rows[0]!.id;
|
||||
};
|
||||
|
||||
const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigint): Promise<void> => {
|
||||
const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigint): Promise<void> => {
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM general ORDER BY id FOR UPDATE`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM auction
|
||||
@@ -155,10 +155,18 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigin
|
||||
ORDER BY id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM message
|
||||
WHERE valid_until_tick IS NOT NULL AND valid_until_tick >= ${cutTick}
|
||||
ORDER BY id FOR UPDATE
|
||||
SELECT bid.id
|
||||
FROM auction_bid AS bid
|
||||
JOIN auction ON auction.id = bid.auction_id
|
||||
WHERE auction.status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
||||
ORDER BY bid.id FOR UPDATE OF bid
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT message_id AS id FROM message_action
|
||||
WHERE status = 'PENDING'
|
||||
ORDER BY message_id FOR UPDATE
|
||||
`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM inheritance_ledger ORDER BY id FOR UPDATE`);
|
||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
|
||||
`);
|
||||
@@ -175,7 +183,8 @@ const readParticipantSnapshots = async (
|
||||
worldStateId: number,
|
||||
cutTick: bigint
|
||||
): Promise<ParticipantSnapshot[]> => {
|
||||
const [world, generals, auctions, messages, votes, pool, npcTokens, commands] = await Promise.all([
|
||||
const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] =
|
||||
await Promise.all([
|
||||
db.worldState.findUniqueOrThrow({
|
||||
where: { id: worldStateId },
|
||||
select: {
|
||||
@@ -188,17 +197,32 @@ const readParticipantSnapshots = async (
|
||||
}),
|
||||
db.general.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, turnTick: true, recentWarTick: true },
|
||||
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
|
||||
}),
|
||||
db.auction.findMany({
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, status: true, openTick: true, closeTick: true },
|
||||
}),
|
||||
db.message.findMany({
|
||||
where: { validUntilTick: { not: null, gte: cutTick } },
|
||||
db.auctionBid.findMany({
|
||||
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, timeTick: true, validUntilTick: true },
|
||||
select: { id: true, occurredGameTick: true },
|
||||
}),
|
||||
db.messageAction.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
orderBy: { messageId: 'asc' },
|
||||
select: {
|
||||
messageId: true,
|
||||
createdGameTick: true,
|
||||
expiresGameTick: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
},
|
||||
}),
|
||||
db.inheritanceLedger.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, appliedClockRevision: true, appliedDeadlineGeneration: true },
|
||||
}),
|
||||
db.votePoll.findMany({
|
||||
where: { closedAt: null },
|
||||
@@ -219,7 +243,7 @@ const readParticipantSnapshots = async (
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true },
|
||||
}),
|
||||
]);
|
||||
]);
|
||||
const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({
|
||||
key,
|
||||
policy,
|
||||
@@ -246,6 +270,18 @@ const readParticipantSnapshots = async (
|
||||
'KEEP',
|
||||
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick }))
|
||||
),
|
||||
snapshot(
|
||||
'selection-reselection-deadline',
|
||||
'SHIFT',
|
||||
generals.flatMap(({ id, meta: generalMeta }) => {
|
||||
const raw =
|
||||
generalMeta && typeof generalMeta === 'object' && !Array.isArray(generalMeta)
|
||||
? Reflect.get(generalMeta, 'next_change_tick')
|
||||
: null;
|
||||
const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
|
||||
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
|
||||
})
|
||||
),
|
||||
snapshot(
|
||||
'auction-open-occurrence',
|
||||
'KEEP',
|
||||
@@ -256,21 +292,34 @@ const readParticipantSnapshots = async (
|
||||
'SHIFT',
|
||||
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
|
||||
),
|
||||
snapshot('auction-bid-occurrence', 'KEEP', auctionBids),
|
||||
snapshot(
|
||||
'auction-finalizing-recovery',
|
||||
'REBUILD',
|
||||
auctions.map(({ id, status }) => ({ id, status }))
|
||||
),
|
||||
snapshot(
|
||||
'message-occurrence',
|
||||
'message-action-occurrence',
|
||||
'KEEP',
|
||||
messages.map(({ id, timeTick }) => ({ id, timeTick }))
|
||||
messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick }))
|
||||
),
|
||||
snapshot(
|
||||
'message-expiry',
|
||||
'message-action-expiry',
|
||||
'SHIFT',
|
||||
messages.map(({ id, validUntilTick }) => ({ id, validUntilTick }))
|
||||
messages
|
||||
.filter(({ expiresGameTick }) => expiresGameTick !== null && expiresGameTick >= cutTick)
|
||||
.map(({ messageId, expiresGameTick }) => ({ messageId, expiresGameTick }))
|
||||
),
|
||||
snapshot(
|
||||
'message-action-clock-coordinate',
|
||||
'REBUILD',
|
||||
messages.map(({ messageId, clockRevision, deadlineGeneration }) => ({
|
||||
messageId,
|
||||
clockRevision,
|
||||
deadlineGeneration,
|
||||
}))
|
||||
),
|
||||
snapshot('inheritance-effect-coordinate', 'KEEP', inheritanceEffects),
|
||||
snapshot(
|
||||
'vote-start-occurrence',
|
||||
'KEEP',
|
||||
@@ -283,7 +332,7 @@ const readParticipantSnapshots = async (
|
||||
),
|
||||
snapshot('select-pool-reservation', 'SHIFT', pool),
|
||||
snapshot('npc-selection-window', 'SHIFT', npcTokens),
|
||||
snapshot('accepted-command-coordinate', 'KEEP', commands),
|
||||
snapshot('daemon-command-coordinate', 'KEEP', commands),
|
||||
snapshot('movable-json-rule-anchors', 'SHIFT', [
|
||||
{
|
||||
lastTurnTime: Reflect.get(meta, 'lastTurnTime'),
|
||||
@@ -429,15 +478,20 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
|
||||
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
||||
const shift = BigInt(shiftTicks);
|
||||
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
||||
const [general, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
||||
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
||||
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
|
||||
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
||||
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
|
||||
FROM general
|
||||
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
|
||||
`),
|
||||
db.auction.aggregate({
|
||||
_max: { closeTick: true },
|
||||
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
|
||||
}),
|
||||
db.message.aggregate({
|
||||
_max: { validUntilTick: true },
|
||||
where: { validUntilTick: { not: null, lt: BigInt(MAX_SAFE_GAME_TICK) } },
|
||||
db.messageAction.aggregate({
|
||||
_max: { expiresGameTick: true },
|
||||
where: { status: 'PENDING', expiresGameTick: { not: null } },
|
||||
}),
|
||||
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
|
||||
db.selectPoolEntry.aggregate({
|
||||
@@ -452,8 +506,9 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
|
||||
]);
|
||||
const values: Array<[string, bigint | null]> = [
|
||||
['general.turn_tick', general._max.turnTick],
|
||||
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
|
||||
['auction.close_tick', auction._max.closeTick],
|
||||
['message.valid_until_tick', message._max.validUntilTick],
|
||||
['message_action.expires_game_tick', message._max.expiresGameTick],
|
||||
['vote_poll.end_tick', vote._max.endTick],
|
||||
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
|
||||
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
|
||||
@@ -493,6 +548,32 @@ const applyParticipantShift = async (
|
||||
WHERE turn_tick IS NOT NULL
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'selection-reselection-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE general
|
||||
SET meta = jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
meta,
|
||||
'{next_change_tick}',
|
||||
to_jsonb((meta->>'next_change_tick')::bigint + ${shiftTicks}),
|
||||
true
|
||||
),
|
||||
'{next_change}',
|
||||
to_jsonb(((meta->>'next_change')::timestamp
|
||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
|
||||
true
|
||||
),
|
||||
'{nextChangeAt}',
|
||||
to_jsonb(((meta->>'nextChangeAt')::timestamp
|
||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
|
||||
true
|
||||
)
|
||||
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
|
||||
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'auction-deadline',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
@@ -504,14 +585,37 @@ const applyParticipantShift = async (
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
'message-expiry',
|
||||
'message-action-clock-coordinate',
|
||||
(
|
||||
await db.messageAction.updateMany({
|
||||
where: { status: 'PENDING' },
|
||||
data: {
|
||||
clockRevision: targetRevision,
|
||||
deadlineGeneration: targetGeneration,
|
||||
},
|
||||
})
|
||||
).count
|
||||
);
|
||||
affected.set(
|
||||
'message-action-expiry',
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE message
|
||||
SET valid_until_tick = valid_until_tick + ${shiftTicks},
|
||||
valid_until = valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
WHERE valid_until_tick IS NOT NULL
|
||||
AND valid_until_tick >= ${cutTick}
|
||||
AND valid_until_tick < ${BigInt(MAX_SAFE_GAME_TICK)}
|
||||
WITH shifted AS (
|
||||
UPDATE message_action
|
||||
SET expires_game_tick = expires_game_tick + ${shiftTicks},
|
||||
clock_revision = ${targetRevision},
|
||||
deadline_generation = ${targetGeneration},
|
||||
updated_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE status = 'PENDING'
|
||||
AND expires_game_tick IS NOT NULL
|
||||
AND expires_game_tick >= ${cutTick}
|
||||
RETURNING message_id, expires_game_tick
|
||||
)
|
||||
UPDATE message AS envelope
|
||||
SET valid_until_tick = shifted.expires_game_tick,
|
||||
valid_until = envelope.valid_until
|
||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
||||
FROM shifted
|
||||
WHERE envelope.id = shifted.message_id
|
||||
`)
|
||||
);
|
||||
affected.set(
|
||||
|
||||
@@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({
|
||||
type: z.literal('auctionFinalize'),
|
||||
auctionId: zFiniteNumber,
|
||||
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
expectedCloseTick: zSafeInteger.optional(),
|
||||
expectedCloseTick: zSafeInteger,
|
||||
});
|
||||
|
||||
const zAuctionOpen = z.object({
|
||||
@@ -60,7 +60,6 @@ const zAuctionBid = z.object({
|
||||
auctionId: zFiniteNumber,
|
||||
generalId: zFiniteNumber,
|
||||
amount: zFiniteNumber,
|
||||
acceptedGameTick: zSafeInteger.optional(),
|
||||
tryExtendCloseDate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -427,7 +426,7 @@ const zSelectPoolReserve = z
|
||||
requestId: z.string().optional(),
|
||||
userId: z.string().min(1),
|
||||
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp),
|
||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
writeReadModelChangeJournal,
|
||||
enqueuePrivateMessageWebPush,
|
||||
enqueueWebPushOutboxEvents,
|
||||
persistMessageEnvelope,
|
||||
type InputJsonValue,
|
||||
type ReadModelJournalWriteResult,
|
||||
type TurnEngineCityUpdateInput,
|
||||
@@ -1862,37 +1863,18 @@ export const createDatabaseTurnHooks = async (
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: async (draft: MessageRecordDraft) => {
|
||||
const toTickOrNull = (date: Date): bigint | null => {
|
||||
try {
|
||||
return BigInt(world.dateToGameTick(date));
|
||||
} catch {
|
||||
// Legacy messages may use year 9999 as an
|
||||
// effectively-unbounded expiry, beyond the
|
||||
// safe JavaScript tick range.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (
|
||||
mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message
|
||||
)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${toTickOrNull(draft.time)},
|
||||
${draft.validUntil},
|
||||
${toTickOrNull(draft.validUntil)},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error('Failed to persist turn message.');
|
||||
}
|
||||
const clock = world.getGameClockState();
|
||||
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
|
||||
const expiresGameTick =
|
||||
typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000
|
||||
? null
|
||||
: BigInt(world.dateToGameTick(draft.validUntil));
|
||||
const id = await persistMessageEnvelope(prisma, draft, {
|
||||
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
deadlineGeneration: BigInt(clock.deadlineGeneration),
|
||||
expiresGameTick,
|
||||
});
|
||||
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
||||
persistedMessageMailboxes.push(draft.mailbox);
|
||||
return id;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||
import { createGatewayPostgresConnector, GatewayPrisma } from '@sammo-ts/infra';
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
|
||||
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
|
||||
@@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async (
|
||||
continue;
|
||||
}
|
||||
const terminal = result.status !== 'PARTIAL';
|
||||
const updated = await prisma.gatewayRuntimeAction.updateMany({
|
||||
where: {
|
||||
id: action.id,
|
||||
status: { in: ['REQUESTED', 'PARTIAL'] },
|
||||
},
|
||||
data: {
|
||||
status: result.status,
|
||||
detail: result.detail ?? null,
|
||||
handler: 'turn-daemon',
|
||||
handledAt: terminal ? new Date() : null,
|
||||
attempts: { increment: 1 },
|
||||
nextAttemptAt: terminal
|
||||
? null
|
||||
: new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))),
|
||||
},
|
||||
});
|
||||
if (terminal && updated.count > 0) {
|
||||
const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6));
|
||||
const updated = await prisma.$queryRaw<Array<{ id: string }>>(GatewayPrisma.sql`
|
||||
UPDATE gateway_runtime_action
|
||||
SET status = ${result.status}::"GatewayRuntimeActionStatus",
|
||||
detail = ${result.detail ?? null},
|
||||
handler = 'turn-daemon',
|
||||
handled_at = CASE
|
||||
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
ELSE NULL
|
||||
END,
|
||||
attempts = attempts + 1,
|
||||
next_attempt_at = CASE
|
||||
WHEN ${terminal} THEN NULL
|
||||
ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
+ ${retryDelayMs} * INTERVAL '1 millisecond'
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${action.id}
|
||||
AND status IN ('REQUESTED'::"GatewayRuntimeActionStatus", 'PARTIAL'::"GatewayRuntimeActionStatus")
|
||||
RETURNING id
|
||||
`);
|
||||
if (terminal && updated.length > 0) {
|
||||
await options.onActionApplied?.(actionRecord, result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
|
||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||
|
||||
@@ -42,7 +44,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
||||
return {
|
||||
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
|
||||
async shouldPause(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const now = performance.now();
|
||||
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
|
||||
return cachedPause;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js';
|
||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||
import type { InMemoryTurnWorld, TurnCalendarContext } from './inMemoryWorld.js';
|
||||
@@ -50,16 +52,16 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
}
|
||||
|
||||
async run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise<TurnRunResult> {
|
||||
const startMs = Date.now();
|
||||
const startMs = performance.now();
|
||||
const deadlineMs = startMs + Math.max(0, budget.budgetMs);
|
||||
const isBudgetExpired = () => Date.now() >= deadlineMs;
|
||||
const isBudgetExpired = () => performance.now() >= deadlineMs;
|
||||
|
||||
if (isWorldUnited(this.world)) {
|
||||
return {
|
||||
lastTurnTime: this.world.getState().lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: Math.max(0, Date.now() - startMs),
|
||||
durationMs: Math.max(0, performance.now() - startMs),
|
||||
partial: false,
|
||||
checkpoint,
|
||||
};
|
||||
@@ -171,7 +173,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
lastTurnTime,
|
||||
processedGenerals,
|
||||
processedTurns,
|
||||
durationMs: Math.max(0, Date.now() - startMs),
|
||||
durationMs: Math.max(0, performance.now() - startMs),
|
||||
partial,
|
||||
checkpoint: nextCheckpoint,
|
||||
};
|
||||
|
||||
@@ -310,7 +310,7 @@ export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
|
||||
return '알수없음';
|
||||
};
|
||||
|
||||
export const executeInheritanceAction = async (options: {
|
||||
const executeInheritanceActionMutation = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
world: InMemoryTurnWorld;
|
||||
command: InheritanceActionCommand;
|
||||
@@ -668,3 +668,59 @@ export const executeInheritanceAction = async (options: {
|
||||
});
|
||||
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost };
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists the WALL_TIME inheritance receipt in the same transaction as the
|
||||
* point debit, game mutation, and input-event completion. The input_event row
|
||||
* is the durable retry/failure record and owns the authoritative GAME clock
|
||||
* coordinate; an immediate effect does not invent a separate applied tick.
|
||||
*/
|
||||
export const executeInheritanceAction = async (options: {
|
||||
db: GamePrisma.TransactionClient;
|
||||
world: InMemoryTurnWorld;
|
||||
command: InheritanceActionCommand;
|
||||
gameNow: Date;
|
||||
}): Promise<InheritanceActionResult> => {
|
||||
const result = await executeInheritanceActionMutation(options);
|
||||
if (!result.ok || !options.command.requestId) return result;
|
||||
|
||||
const event = await options.db.inputEvent.findUnique({
|
||||
where: { requestId: options.command.requestId },
|
||||
select: {
|
||||
actorUserId: true,
|
||||
target: true,
|
||||
eventType: true,
|
||||
createdAt: true,
|
||||
processingClockRevision: true,
|
||||
processingDeadlineGeneration: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!event ||
|
||||
event.actorUserId !== options.command.userId ||
|
||||
event.target !== 'ENGINE' ||
|
||||
event.eventType !== 'inheritanceAction' ||
|
||||
event.processingClockRevision === null ||
|
||||
event.processingDeadlineGeneration === null
|
||||
) {
|
||||
throw new Error('Inheritance ledger requires the authoritative ENGINE input-event clock fence.');
|
||||
}
|
||||
const previousPoint = await lockPreviousPoint(options.db, options.command.userId);
|
||||
const cost = previousPoint - result.remainPoint;
|
||||
if (!Number.isFinite(cost) || cost < 0) {
|
||||
throw new Error(`Inheritance ledger calculated an invalid cost: ${cost}.`);
|
||||
}
|
||||
await options.db.inheritanceLedger.create({
|
||||
data: {
|
||||
requestId: options.command.requestId,
|
||||
userId: options.command.userId,
|
||||
action: result.action,
|
||||
cost,
|
||||
status: 'APPLIED',
|
||||
requestedAtWall: event.createdAt,
|
||||
appliedClockRevision: event.processingClockRevision,
|
||||
appliedDeadlineGeneration: event.processingDeadlineGeneration,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
GamePrisma,
|
||||
@@ -84,7 +84,9 @@ export interface NpcPossessionSelectionObserver {
|
||||
interface NpcSelectionTokenRow {
|
||||
ownerUserId: string;
|
||||
validUntil: Date;
|
||||
validUntilTick: bigint | null;
|
||||
pickMoreFrom: Date;
|
||||
pickMoreFromTick: bigint | null;
|
||||
pickResult: unknown;
|
||||
nonce: number;
|
||||
}
|
||||
@@ -105,8 +107,8 @@ const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTi
|
||||
export const buildNpcSelectionTokenSeed = (
|
||||
hiddenSeed: string | number,
|
||||
ownerIdentity: string | number,
|
||||
acceptedGameTick: number
|
||||
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick);
|
||||
createdGameTick: number
|
||||
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, createdGameTick);
|
||||
|
||||
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
@@ -159,15 +161,22 @@ const parsePickResult = (value: unknown): Record<string, NpcPossessionCandidate>
|
||||
};
|
||||
|
||||
const toReservation = (
|
||||
token: Pick<NpcSelectionTokenRow, 'validUntil' | 'pickMoreFrom' | 'pickResult' | 'nonce'>,
|
||||
now: Date
|
||||
token: Pick<
|
||||
NpcSelectionTokenRow,
|
||||
'validUntil' | 'validUntilTick' | 'pickMoreFrom' | 'pickMoreFromTick' | 'pickResult' | 'nonce'
|
||||
>,
|
||||
currentGameTick: number,
|
||||
ticksPerSecond: number
|
||||
): NpcPossessionReservation => {
|
||||
if (token.validUntilTick === null || token.pickMoreFromTick === null) {
|
||||
return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보의 GAME_TIME 기한이 없습니다.');
|
||||
}
|
||||
const pickResult = parsePickResult(token.pickResult);
|
||||
return {
|
||||
tokenNonce: token.nonce,
|
||||
validUntil: token.validUntil.toISOString(),
|
||||
pickMoreFrom: token.pickMoreFrom.toISOString(),
|
||||
pickMoreSeconds: Math.max(0, Math.ceil((token.pickMoreFrom.getTime() - now.getTime()) / 1000)),
|
||||
pickMoreSeconds: Math.max(0, Math.ceil((Number(token.pickMoreFromTick) - currentGameTick) / ticksPerSecond)),
|
||||
candidates: Object.values(pickResult).sort(
|
||||
(left, right) =>
|
||||
left.stats.leadership +
|
||||
@@ -289,16 +298,18 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
refresh?: boolean;
|
||||
keepIds?: number[];
|
||||
now?: Date;
|
||||
acceptedGameTick: number;
|
||||
createdGameTick: number;
|
||||
selectionObserver?: NpcPossessionSelectionObserver;
|
||||
}): Promise<NpcPossessionReservation> => {
|
||||
const { db, worldState, userId } = options;
|
||||
requireNpcPossessionWorld(worldState);
|
||||
const now = truncateToSeconds(options.now ?? new Date());
|
||||
if (!Number.isSafeInteger(options.acceptedGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.');
|
||||
if (!Number.isSafeInteger(options.createdGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 생성 tick이 올바르지 않습니다.');
|
||||
}
|
||||
await lockNpcPossession(db, userId);
|
||||
const turnTermMinutes = resolveTurnTermMinutes(worldState);
|
||||
const ticksPerSecond = GAME_TICKS_PER_TURN / (turnTermMinutes * 60);
|
||||
|
||||
if (await db.general.findFirst({ where: { userId }, select: { id: true } })) {
|
||||
fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다');
|
||||
@@ -324,14 +335,20 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
if (options.refresh) {
|
||||
fail('CONFLICT', 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.');
|
||||
}
|
||||
return toReservation(inFlightToken, now);
|
||||
return toReservation(inFlightToken, options.createdGameTick, ticksPerSecond);
|
||||
}
|
||||
if (existing && existing.validUntil.getTime() < now.getTime()) {
|
||||
if (
|
||||
existing &&
|
||||
(existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick)
|
||||
) {
|
||||
await db.npcSelectionToken.deleteMany({
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
nonce: existing.nonce,
|
||||
validUntil: { lt: now },
|
||||
OR: [
|
||||
{ validUntilTick: null },
|
||||
{ validUntilTick: { lt: BigInt(options.createdGameTick) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
existing = null;
|
||||
@@ -339,7 +356,7 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
|
||||
const kept: Record<string, NpcPossessionCandidate> = {};
|
||||
if (existing && options.refresh) {
|
||||
if (now.getTime() < existing.pickMoreFrom.getTime()) {
|
||||
if (existing.pickMoreFromTick === null || options.createdGameTick < Number(existing.pickMoreFromTick)) {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다');
|
||||
}
|
||||
const oldPick = parsePickResult(existing.pickResult);
|
||||
@@ -352,16 +369,16 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
}
|
||||
// Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다.
|
||||
if (Object.keys(kept).length === Object.keys(oldPick).length) {
|
||||
return toReservation(existing, now);
|
||||
return toReservation(existing, options.createdGameTick, ticksPerSecond);
|
||||
}
|
||||
} else if (existing) {
|
||||
return toReservation(existing, now);
|
||||
return toReservation(existing, options.createdGameTick, ticksPerSecond);
|
||||
}
|
||||
|
||||
const reservedRows = await db.npcSelectionToken.findMany({
|
||||
where: {
|
||||
ownerUserId: { not: userId },
|
||||
validUntil: { gte: now },
|
||||
validUntilTick: { gte: BigInt(options.createdGameTick) },
|
||||
},
|
||||
select: { pickResult: true },
|
||||
});
|
||||
@@ -397,16 +414,19 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
|
||||
);
|
||||
const selectionRng = new LiteHashDRBG(
|
||||
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick)
|
||||
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.createdGameTick)
|
||||
);
|
||||
const rng = options.selectionObserver?.onRandomDraw
|
||||
? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw)
|
||||
: new RandUtil(selectionRng);
|
||||
const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw);
|
||||
const turnTermMinutes = resolveTurnTermMinutes(worldState);
|
||||
const validUntil = new Date(now.getTime() + Math.max(VALID_SECONDS, turnTermMinutes * 40) * 1000);
|
||||
const validSeconds = Math.max(VALID_SECONDS, turnTermMinutes * 40);
|
||||
const pickMoreSeconds = Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8));
|
||||
const validUntilTick = options.createdGameTick + Math.round(validSeconds * ticksPerSecond);
|
||||
const pickMoreFromTick = options.createdGameTick + Math.round(pickMoreSeconds * ticksPerSecond);
|
||||
const validUntil = new Date(now.getTime() + validSeconds * 1000);
|
||||
const refreshedPickMoreFrom = new Date(
|
||||
now.getTime() + Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8)) * 1000
|
||||
now.getTime() + pickMoreSeconds * 1000
|
||||
);
|
||||
const nonce = randomInt(0, 0x10000000);
|
||||
|
||||
@@ -415,7 +435,9 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
where: { ownerUserId: userId, nonce: existing.nonce },
|
||||
data: {
|
||||
validUntil,
|
||||
validUntilTick: BigInt(validUntilTick),
|
||||
pickMoreFrom: refreshedPickMoreFrom,
|
||||
pickMoreFromTick: BigInt(pickMoreFromTick),
|
||||
pickResult: pickResult as GamePrisma.InputJsonValue,
|
||||
nonce,
|
||||
},
|
||||
@@ -423,7 +445,18 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
if (updated.count === 0) {
|
||||
fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요');
|
||||
}
|
||||
return toReservation({ validUntil, pickMoreFrom: refreshedPickMoreFrom, pickResult, nonce }, now);
|
||||
return toReservation(
|
||||
{
|
||||
validUntil,
|
||||
validUntilTick: BigInt(validUntilTick),
|
||||
pickMoreFrom: refreshedPickMoreFrom,
|
||||
pickMoreFromTick: BigInt(pickMoreFromTick),
|
||||
pickResult,
|
||||
nonce,
|
||||
},
|
||||
options.createdGameTick,
|
||||
ticksPerSecond
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -431,7 +464,9 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
data: {
|
||||
ownerUserId: userId,
|
||||
validUntil,
|
||||
validUntilTick: BigInt(validUntilTick),
|
||||
pickMoreFrom: FIRST_PICK_MORE_FROM,
|
||||
pickMoreFromTick: BigInt(options.createdGameTick),
|
||||
pickResult: pickResult as GamePrisma.InputJsonValue,
|
||||
nonce,
|
||||
},
|
||||
@@ -442,7 +477,18 @@ export const reserveNpcPossessionCandidates = async (options: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return toReservation({ validUntil, pickMoreFrom: FIRST_PICK_MORE_FROM, pickResult, nonce }, now);
|
||||
return toReservation(
|
||||
{
|
||||
validUntil,
|
||||
validUntilTick: BigInt(validUntilTick),
|
||||
pickMoreFrom: FIRST_PICK_MORE_FROM,
|
||||
pickMoreFromTick: BigInt(options.createdGameTick),
|
||||
pickResult,
|
||||
nonce,
|
||||
},
|
||||
options.createdGameTick,
|
||||
ticksPerSecond
|
||||
);
|
||||
};
|
||||
|
||||
export const possessNpcGeneral = async (options: {
|
||||
@@ -455,11 +501,13 @@ export const possessNpcGeneral = async (options: {
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
generalId: number;
|
||||
tokenNonce: number;
|
||||
acceptedAt: Date;
|
||||
requestedAtWall: Date;
|
||||
processingGameTick: number;
|
||||
}): Promise<{ ok: true; generalId: number }> => {
|
||||
const { db, world, worldState, userId, generalId, acceptedAt } = options;
|
||||
// queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다.
|
||||
const tokenAcceptedAt = truncateToSeconds(acceptedAt);
|
||||
const { db, world, worldState, userId, generalId, requestedAtWall } = options;
|
||||
if (!Number.isSafeInteger(options.processingGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 처리 tick이 올바르지 않습니다.');
|
||||
}
|
||||
requireNpcPossessionWorld(worldState);
|
||||
await lockNpcPossession(db, userId);
|
||||
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
|
||||
@@ -475,7 +523,7 @@ export const possessNpcGeneral = async (options: {
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
nonce: options.tokenNonce,
|
||||
validUntil: { gte: tokenAcceptedAt },
|
||||
validUntilTick: { gte: BigInt(options.processingGameTick) },
|
||||
},
|
||||
})) as NpcSelectionTokenRow | null;
|
||||
if (!token) {
|
||||
@@ -501,7 +549,7 @@ export const possessNpcGeneral = async (options: {
|
||||
return fail('NOT_FOUND', '장수 등록에 실패했습니다.');
|
||||
}
|
||||
|
||||
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, acceptedAt);
|
||||
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, requestedAtWall);
|
||||
world.updateGeneral(generalId, {
|
||||
userId,
|
||||
npcState: 1,
|
||||
@@ -522,7 +570,7 @@ export const possessNpcGeneral = async (options: {
|
||||
where: { generalId },
|
||||
update: {
|
||||
userId,
|
||||
lastRefresh: acceptedAt,
|
||||
lastRefresh: requestedAtWall,
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
@@ -531,7 +579,7 @@ export const possessNpcGeneral = async (options: {
|
||||
create: {
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: acceptedAt,
|
||||
lastRefresh: requestedAtWall,
|
||||
},
|
||||
});
|
||||
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId } });
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
GamePrisma,
|
||||
type InputJsonValue,
|
||||
type TurnEngineDatabaseClient,
|
||||
} from '@sammo-ts/infra';
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@@ -75,6 +80,7 @@ const buildTurnListFromRows = (
|
||||
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
|
||||
|
||||
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & {
|
||||
$queryRaw?<T>(query: GamePrisma.Sql): Promise<T>;
|
||||
generalTurnRevision?: Pick<
|
||||
NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>,
|
||||
'findUnique' | 'createMany' | 'updateMany'
|
||||
@@ -313,8 +319,17 @@ export class InMemoryReservedTurnStore {
|
||||
}
|
||||
}
|
||||
|
||||
private getLeaseExpiresAt(): Date {
|
||||
return new Date(Date.now() + this.leaseDurationMs);
|
||||
private getLeaseExpiresAt(nowWall: Date): Date {
|
||||
return new Date(nowWall.getTime() + this.leaseDurationMs);
|
||||
}
|
||||
|
||||
private async readDatabaseWallTime(prisma: ReservedTurnDatabaseClient = this.prisma): Promise<Date> {
|
||||
if (!prisma.$queryRaw) return new Date();
|
||||
const rows = await prisma.$queryRaw<Array<{ nowWall: Date }>>(GamePrisma.sql`
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "nowWall"
|
||||
`);
|
||||
if (!rows[0]) throw new Error('PostgreSQL did not return its authoritative wall clock.');
|
||||
return rows[0].nowWall;
|
||||
}
|
||||
|
||||
private async acquireGeneralLease(generalId: number): Promise<boolean> {
|
||||
@@ -322,7 +337,7 @@ export class InMemoryReservedTurnStore {
|
||||
if (!revisionStore) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date();
|
||||
const now = await this.readDatabaseWallTime();
|
||||
const previous = (await revisionStore.findUnique({ where: { generalId } })) as {
|
||||
leaseOwner: string | null;
|
||||
leaseExpiresAt: Date | null;
|
||||
@@ -331,7 +346,7 @@ export class InMemoryReservedTurnStore {
|
||||
previous?.leaseOwner === this.leaseOwner &&
|
||||
previous.leaseExpiresAt !== null &&
|
||||
previous.leaseExpiresAt.getTime() > now.getTime();
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where: {
|
||||
generalId,
|
||||
@@ -372,7 +387,7 @@ export class InMemoryReservedTurnStore {
|
||||
if (!revisionStore) {
|
||||
return false;
|
||||
}
|
||||
const now = new Date();
|
||||
const now = await this.readDatabaseWallTime();
|
||||
const previous = (await revisionStore.findUnique({
|
||||
where: { nationId_officerLevel: { nationId, officerLevel } },
|
||||
})) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null;
|
||||
@@ -380,7 +395,7 @@ export class InMemoryReservedTurnStore {
|
||||
previous?.leaseOwner === this.leaseOwner &&
|
||||
previous.leaseExpiresAt !== null &&
|
||||
previous.leaseExpiresAt.getTime() > now.getTime();
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where: {
|
||||
nationId,
|
||||
@@ -651,12 +666,13 @@ export class InMemoryReservedTurnStore {
|
||||
if (!revisionStore) {
|
||||
return false;
|
||||
}
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
||||
const now = await this.readDatabaseWallTime(prisma);
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||
const where = this.leasedGeneralIds.has(generalId)
|
||||
? { generalId, leaseOwner: this.leaseOwner }
|
||||
: {
|
||||
generalId,
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
||||
};
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where,
|
||||
@@ -721,13 +737,14 @@ export class InMemoryReservedTurnStore {
|
||||
if (!revisionStore) {
|
||||
return false;
|
||||
}
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
||||
const now = await this.readDatabaseWallTime(prisma);
|
||||
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||
const where = this.leasedNationKeys.has(key)
|
||||
? { nationId, officerLevel, leaseOwner: this.leaseOwner }
|
||||
: {
|
||||
nationId,
|
||||
officerLevel,
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
||||
};
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
@@ -89,8 +90,8 @@ const shiftTournamentClock = async (
|
||||
};
|
||||
const lockKey = `${stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + 2_000;
|
||||
while (performance.now() < deadline) {
|
||||
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||
if (acquired) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
@@ -158,8 +159,8 @@ const reprojectTournamentClock = async (
|
||||
};
|
||||
const lockKey = `${stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + 2_000;
|
||||
while (performance.now() < deadline) {
|
||||
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||
if (acquired) {
|
||||
try {
|
||||
|
||||
@@ -268,13 +268,10 @@ const toReservationDto = (
|
||||
world: InMemoryTurnWorld
|
||||
): Promise<SelectPoolReservationDto> => {
|
||||
const first = rows[0];
|
||||
if (!first || (first.reservedUntilTick === null && first.reservedUntil === null)) {
|
||||
if (!first || first.reservedUntilTick === null) {
|
||||
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
|
||||
}
|
||||
const expiresAt =
|
||||
first.reservedUntilTick === null
|
||||
? first.reservedUntil!
|
||||
: world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
|
||||
const expiresAt = world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
|
||||
const poolName = resolvePoolName(worldState);
|
||||
if (!poolName || !SUPPORTED_POOLS.has(poolName)) {
|
||||
throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
|
||||
@@ -355,6 +352,23 @@ const readNextChangeAt = (generalMeta: unknown): Date | null => {
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const readNextChangeTick = (generalMeta: unknown): number | null => {
|
||||
const raw = asRecord(generalMeta).next_change_tick;
|
||||
const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN;
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const assertReselectionCooldown = (generalMeta: unknown, processingGameTick: number): void => {
|
||||
const projection = readNextChangeAt(generalMeta);
|
||||
const deadline = readNextChangeTick(generalMeta);
|
||||
if (projection && deadline === null) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 재선택 cooldown의 GAME_TIME authority가 없습니다.');
|
||||
}
|
||||
if (deadline !== null && deadline > processingGameTick) {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
||||
}
|
||||
};
|
||||
|
||||
const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
const value = meta.hiddenSeed ?? meta.seed;
|
||||
@@ -371,18 +385,8 @@ const toSafeReservationTick = (value: bigint | number, uniqueName: string): numb
|
||||
return tick;
|
||||
};
|
||||
|
||||
const resolveAcceptedGameTick = (world: InMemoryTurnWorld, now: Date): number => {
|
||||
const tick = world.dateToGameTick(now);
|
||||
if (!Number.isSafeInteger(tick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
return tick;
|
||||
};
|
||||
|
||||
const isReservationActive = (row: SelectPoolRow, now: Date, nowTick: number): boolean =>
|
||||
row.reservedUntilTick !== null
|
||||
? toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick
|
||||
: row.reservedUntil !== null && row.reservedUntil.getTime() >= now.getTime();
|
||||
const isReservationActive = (row: SelectPoolRow, nowTick: number): boolean =>
|
||||
row.reservedUntilTick !== null && toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick;
|
||||
|
||||
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
|
||||
@@ -392,7 +396,6 @@ const requireSelectionToken = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
uniqueName: string,
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): Promise<SelectPoolRow> => {
|
||||
const token = await db.selectPoolEntry.findFirst({
|
||||
@@ -400,10 +403,7 @@ const requireSelectionToken = async (
|
||||
ownerUserId: userId,
|
||||
uniqueName,
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
||||
],
|
||||
reservedUntilTick: { gte: BigInt(nowTick) },
|
||||
},
|
||||
});
|
||||
if (!token) {
|
||||
@@ -438,18 +438,13 @@ export const reserveSelectionPool = async (options: {
|
||||
worldState: WorldStateRow;
|
||||
userId: string;
|
||||
now?: Date;
|
||||
acceptedGameTick?: number;
|
||||
processingGameTick?: number;
|
||||
processingGameTick: number;
|
||||
seedOwnerIdentity?: string | number;
|
||||
}): Promise<SelectPoolReservationDto> => {
|
||||
const { db, world, worldState, userId } = options;
|
||||
requirePoolWorld(worldState);
|
||||
const now = options.now ?? new Date();
|
||||
const acceptedGameTick = options.acceptedGameTick ?? resolveAcceptedGameTick(world, now);
|
||||
const processingGameTick = options.processingGameTick ?? acceptedGameTick;
|
||||
if (!Number.isSafeInteger(acceptedGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
const processingGameTick = options.processingGameTick;
|
||||
if (!Number.isSafeInteger(processingGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
@@ -459,14 +454,11 @@ export const reserveSelectionPool = async (options: {
|
||||
where: { userId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
const nextChangeAt = general ? readNextChangeAt(general.meta) : null;
|
||||
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
||||
}
|
||||
if (general) assertReselectionCooldown(general.meta, processingGameTick);
|
||||
|
||||
let currentRows = await synchronizeSelectionPoolWorld(db, world);
|
||||
const existing = currentRows.filter(
|
||||
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, now, processingGameTick)
|
||||
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, processingGameTick)
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return toReservationDto(existing, Boolean(general), worldState, world);
|
||||
@@ -477,7 +469,7 @@ export const reserveSelectionPool = async (options: {
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { lt: BigInt(processingGameTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { lt: now } },
|
||||
{ reservedUntilTick: null, reservedUntil: { not: null } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
@@ -504,7 +496,7 @@ export const reserveSelectionPool = async (options: {
|
||||
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, acceptedGameTick)
|
||||
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, processingGameTick)
|
||||
)
|
||||
);
|
||||
const poolName = resolvePoolName(worldState)!;
|
||||
@@ -578,7 +570,6 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor
|
||||
const clearUnusedReservations = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): Promise<void> => {
|
||||
await db.selectPoolEntry.updateMany({
|
||||
@@ -587,7 +578,7 @@ const clearUnusedReservations = async (
|
||||
OR: [
|
||||
{ ownerUserId: userId },
|
||||
{ reservedUntilTick: { lt: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { lt: now } },
|
||||
{ reservedUntilTick: null, reservedUntil: { not: null } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
@@ -716,6 +707,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
now?: Date;
|
||||
turnScheduleAt?: Date;
|
||||
operationalAcceptedAt: Date;
|
||||
processingGameTick: number;
|
||||
seedOwnerIdentity?: string | number;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
@@ -724,7 +716,10 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
||||
requirePoolWorld(worldState);
|
||||
const now = options.now ?? new Date();
|
||||
const nowTick = resolveAcceptedGameTick(world, now);
|
||||
const nowTick = options.processingGameTick;
|
||||
if (!Number.isSafeInteger(nowTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 생성 처리 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
await lockSelectionUser(db, userId);
|
||||
await lockSelectionMutationTables(db);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
@@ -735,7 +730,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
) {
|
||||
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.');
|
||||
}
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
|
||||
const info = parseCandidate(token);
|
||||
const poolName = resolvePoolName(worldState)!;
|
||||
const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL;
|
||||
@@ -777,9 +772,8 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
|
||||
const age = 20;
|
||||
const specialityAges = resolveSpecialityAges(worldState, age);
|
||||
const nextChangeAt = new Date(
|
||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||
);
|
||||
const nextChangeTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
|
||||
const nextChangeAt = world.gameTickToDate(nextChangeTick);
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
|
||||
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
|
||||
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
|
||||
@@ -813,6 +807,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
dex5: isCentennial ? 0 : info.dex[4],
|
||||
next_change: nextChangeAt.toISOString(),
|
||||
nextChangeAt: nextChangeAt.toISOString(),
|
||||
next_change_tick: nextChangeTick,
|
||||
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||
...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}),
|
||||
npc_org: 0,
|
||||
@@ -905,10 +900,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
id: token.id,
|
||||
ownerUserId: userId,
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
||||
],
|
||||
reservedUntilTick: { gte: BigInt(nowTick) },
|
||||
},
|
||||
data: {
|
||||
generalId,
|
||||
@@ -925,7 +917,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
update: { userId, lastRefresh: options.operationalAcceptedAt },
|
||||
create: { generalId, userId, lastRefresh: options.operationalAcceptedAt },
|
||||
});
|
||||
await clearUnusedReservations(db, userId, now, nowTick);
|
||||
await clearUnusedReservations(db, userId, nowTick);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
|
||||
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
||||
@@ -949,11 +941,15 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
ownerDisplayName: string;
|
||||
uniqueName: string;
|
||||
now?: Date;
|
||||
processingGameTick: number;
|
||||
}): Promise<{ ok: true; generalId: number }> => {
|
||||
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
||||
requirePoolWorld(worldState);
|
||||
const now = options.now ?? new Date();
|
||||
const nowTick = resolveAcceptedGameTick(world, now);
|
||||
const nowTick = options.processingGameTick;
|
||||
if (!Number.isSafeInteger(nowTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 재선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
await lockSelectionUser(db, userId);
|
||||
await lockSelectionMutationTables(db);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
@@ -968,11 +964,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
if (persistedGeneral.id !== general.id) {
|
||||
fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.');
|
||||
}
|
||||
const nextChangeAt = readNextChangeAt(general.meta);
|
||||
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
||||
}
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
|
||||
assertReselectionCooldown(general.meta, nowTick);
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
|
||||
const info = parseCandidate(token);
|
||||
const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL;
|
||||
|
||||
@@ -982,10 +975,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
id: token.id,
|
||||
ownerUserId: userId,
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
||||
],
|
||||
reservedUntilTick: { gte: BigInt(nowTick) },
|
||||
},
|
||||
data: {
|
||||
generalId: provisionalGeneralId,
|
||||
@@ -1014,9 +1004,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
|
||||
}
|
||||
|
||||
const cooldown = new Date(
|
||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||
);
|
||||
const cooldownTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
|
||||
const cooldown = world.gameTickToDate(cooldownTick);
|
||||
const centennialBaseGeneral = isCentennial
|
||||
? {
|
||||
...general,
|
||||
@@ -1046,6 +1035,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
: {}),
|
||||
next_change: cooldown.toISOString(),
|
||||
nextChangeAt: cooldown.toISOString(),
|
||||
next_change_tick: cooldownTick,
|
||||
...buildScenarioGeneralPoolClaimMeta(
|
||||
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
|
||||
now
|
||||
@@ -1074,7 +1064,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
if (!updated) {
|
||||
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
|
||||
}
|
||||
await clearUnusedReservations(db, userId, now, nowTick);
|
||||
await clearUnusedReservations(db, userId, nowTick);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
|
||||
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
|
||||
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
enqueuePrivateMessageWebPush,
|
||||
persistMessageEnvelope,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
|
||||
import {
|
||||
@@ -119,22 +123,18 @@ interface HighestUnificationBidRow {
|
||||
meta: unknown;
|
||||
}
|
||||
|
||||
const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: MessageRecordDraft): Promise<number> => {
|
||||
const rows = await transaction.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${draft.validUntil},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
|
||||
const insertMessage = async (
|
||||
transaction: GamePrisma.TransactionClient,
|
||||
world: InMemoryTurnWorld,
|
||||
draft: MessageRecordDraft
|
||||
): Promise<number> => {
|
||||
const clock = world.getGameClockState();
|
||||
const id = await persistMessageEnvelope(transaction, draft, {
|
||||
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
|
||||
clockRevision: BigInt(clock.revision),
|
||||
deadlineGeneration: BigInt(clock.deadlineGeneration),
|
||||
expiresGameTick: null,
|
||||
});
|
||||
await enqueuePrivateMessageWebPush(transaction, draft, id);
|
||||
return id;
|
||||
};
|
||||
@@ -252,7 +252,7 @@ const cancelPendingUniqueAuctions = async (
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: async (draft) => {
|
||||
const messageId = await insertMessage(transaction, draft);
|
||||
const messageId = await insertMessage(transaction, world, draft);
|
||||
messageMailboxes.push(draft.mailbox);
|
||||
return messageId;
|
||||
},
|
||||
|
||||
@@ -278,18 +278,12 @@ const resolveSelectionCommandAcceptedAt = async (
|
||||
world: InMemoryTurnWorld,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }>
|
||||
): Promise<Date> => {
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
await resolveCommandAcceptedAt(db, command);
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) {
|
||||
return world.gameTickToDate(processingGameTick);
|
||||
}
|
||||
if (command.acceptedGameTick !== undefined) {
|
||||
return world.gameTickToDate(command.acceptedGameTick);
|
||||
}
|
||||
if (command.acceptedGameAt !== undefined) {
|
||||
return new Date(command.acceptedGameAt);
|
||||
}
|
||||
return world.getGameNow(operationalAcceptedAt);
|
||||
throw new Error(`${command.type} requires an authoritative daemon processing game tick.`);
|
||||
};
|
||||
|
||||
const resolveOperationalAcceptedAt = async (
|
||||
@@ -417,12 +411,9 @@ async function handleNpcPossessGeneral(
|
||||
}
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
const acceptedAt =
|
||||
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)
|
||||
? ctx.world.gameTickToDate(processingGameTick)
|
||||
: command.acceptedGameAt
|
||||
? new Date(command.acceptedGameAt)
|
||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('npcPossessGeneral requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
try {
|
||||
return {
|
||||
type: 'npcPossessGeneral',
|
||||
@@ -436,7 +427,8 @@ async function handleNpcPossessGeneral(
|
||||
...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}),
|
||||
generalId: command.generalId,
|
||||
tokenNonce: command.tokenNonce,
|
||||
acceptedAt,
|
||||
requestedAtWall: operationalAcceptedAt,
|
||||
processingGameTick,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -465,15 +457,11 @@ async function handleSelectPoolCreate(
|
||||
}
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
const acceptedAt =
|
||||
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)
|
||||
? ctx.world.gameTickToDate(processingGameTick)
|
||||
: command.acceptedGameTick !== undefined
|
||||
? ctx.world.gameTickToDate(command.acceptedGameTick)
|
||||
: command.acceptedGameAt !== undefined
|
||||
? new Date(command.acceptedGameAt)
|
||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
||||
const turnScheduleAt = acceptedAt;
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolCreate',
|
||||
@@ -492,6 +480,7 @@ async function handleSelectPoolCreate(
|
||||
now: acceptedAt,
|
||||
turnScheduleAt,
|
||||
operationalAcceptedAt,
|
||||
processingGameTick,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -530,10 +519,7 @@ async function handleSelectPoolReserve(
|
||||
userId: command.userId,
|
||||
seedOwnerIdentity: command.seedOwnerIdentity,
|
||||
now: acceptedAt,
|
||||
...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }),
|
||||
...(typeof Reflect.get(command, 'processingGameTick') === 'number'
|
||||
? { processingGameTick: Reflect.get(command, 'processingGameTick') as number }
|
||||
: {}),
|
||||
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -572,6 +558,7 @@ async function handleSelectPoolReselect(
|
||||
ownerDisplayName: command.ownerDisplayName,
|
||||
uniqueName: command.uniqueName,
|
||||
now: acceptedAt,
|
||||
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -2745,17 +2732,16 @@ type VotePollValidationRow = {
|
||||
|
||||
export const hasVotePollDeadlinePassed = (
|
||||
poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>,
|
||||
acceptedGameAt: Date,
|
||||
acceptedGameTick: number
|
||||
currentGameTick: number
|
||||
): boolean => {
|
||||
const endTick =
|
||||
poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick);
|
||||
return (
|
||||
poll.closedAt !== null ||
|
||||
(endTick !== null
|
||||
? endTick < BigInt(acceptedGameTick)
|
||||
: Boolean(poll.endAt && poll.endAt.getTime() < acceptedGameAt.getTime()))
|
||||
);
|
||||
if (poll.closedAt !== null) return true;
|
||||
// No projection and no tick means an intentionally unbounded poll. A
|
||||
// projection without its authoritative tick is a broken GAME deadline and
|
||||
// therefore fails closed.
|
||||
if (endTick === null) return poll.endAt !== null;
|
||||
return endTick < BigInt(currentGameTick);
|
||||
};
|
||||
|
||||
const parseVoteOptionCount = (value: unknown): number => {
|
||||
@@ -2790,17 +2776,11 @@ const validateVoteSelectionInTransaction = async (
|
||||
const poll = rows[0];
|
||||
if (!poll) return '설문조사가 없습니다.';
|
||||
|
||||
const processingNow = ctx.world.getGameNow(new Date());
|
||||
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
|
||||
const acceptedGameTick =
|
||||
typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick)
|
||||
? convertedProcessingTick
|
||||
: (command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow));
|
||||
const acceptedGameAt =
|
||||
command.acceptedGameTick === undefined && convertedProcessingTick === undefined
|
||||
? processingNow
|
||||
: ctx.world.gameTickToDate(acceptedGameTick);
|
||||
if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) {
|
||||
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
|
||||
throw new Error('voteReward requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
if (hasVotePollDeadlinePassed(poll, convertedProcessingTick)) {
|
||||
return '설문조사가 종료되었습니다.';
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ const destination = {
|
||||
color: '#ffffff',
|
||||
icon: '',
|
||||
};
|
||||
const requestId = 'actionable-message-request';
|
||||
|
||||
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
|
||||
id: 29,
|
||||
@@ -94,6 +95,10 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
|
||||
type: 'private',
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
actionType: action,
|
||||
actionStatus: 'PENDING',
|
||||
createdGameTick: 0n,
|
||||
expiresGameTick: null,
|
||||
message: {
|
||||
src: source,
|
||||
dest: destination,
|
||||
@@ -106,10 +111,25 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
|
||||
const buildDb = (rows: unknown[][]) => {
|
||||
const queryRaw = vi.fn(async () => rows.shift() ?? []);
|
||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const actionUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
return {
|
||||
db: { $queryRaw: queryRaw, message: { updateMany } } as unknown as GamePrisma.TransactionClient,
|
||||
db: {
|
||||
$queryRaw: queryRaw,
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
actorUserId: actor.userId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'messageRespond',
|
||||
createdAt: new Date('2026-09-03T00:00:00.000Z'),
|
||||
processingGameTick: 0n,
|
||||
})),
|
||||
},
|
||||
message: { updateMany },
|
||||
messageAction: { updateMany: actionUpdateMany },
|
||||
} as unknown as GamePrisma.TransactionClient,
|
||||
queryRaw,
|
||||
updateMany,
|
||||
actionUpdateMany,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -118,6 +138,22 @@ const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({
|
||||
});
|
||||
|
||||
describe('actionable message response', () => {
|
||||
it('rejects a response without the authoritative durable command boundary', async () => {
|
||||
const world = buildWorld();
|
||||
const { db } = buildDb([[buildRow('scout')]]);
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: 29,
|
||||
response: true,
|
||||
})
|
||||
).rejects.toThrow('durable ENGINE input event requestId');
|
||||
});
|
||||
|
||||
it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout');
|
||||
@@ -128,6 +164,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -162,6 +199,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(false),
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -173,14 +211,8 @@ describe('actionable message response', () => {
|
||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => {
|
||||
for (const row of [
|
||||
buildRow('scout', { option: { action: 'scout', used: 1 } }),
|
||||
{
|
||||
...buildRow('scout'),
|
||||
validUntil: new Date('0199-12-31T23:59:59.000Z'),
|
||||
},
|
||||
]) {
|
||||
it('treats a legacy truthy used value as an invalid scout letter', async () => {
|
||||
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
|
||||
const world = buildWorld();
|
||||
const { db, updateMany } = buildDb([[row]]);
|
||||
const executor = buildExecutor();
|
||||
@@ -190,6 +222,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -201,6 +234,24 @@ describe('actionable message response', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an expired GAME_TIME action row as absent', async () => {
|
||||
const world = buildWorld();
|
||||
const { db } = buildDb([[]]);
|
||||
|
||||
await expect(
|
||||
respondToActionableMessage({
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: 29,
|
||||
response: true,
|
||||
})
|
||||
).resolves.toEqual({ ok: false, reason: '존재하지 않는 메시지입니다.' });
|
||||
});
|
||||
|
||||
it("keeps PHP's special string-zero used value false", async () => {
|
||||
const world = buildWorld();
|
||||
const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
|
||||
@@ -212,6 +263,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -233,6 +285,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor,
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -252,6 +305,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
@@ -271,6 +325,7 @@ describe('actionable message response', () => {
|
||||
db,
|
||||
world,
|
||||
executor: buildExecutor(),
|
||||
requestId,
|
||||
userId: actor.userId!,
|
||||
generalId: actor.id,
|
||||
messageId: row.id,
|
||||
|
||||
@@ -107,6 +107,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'],
|
||||
});
|
||||
const amount = finishImmediately ? 500 : 200;
|
||||
const requestedAtWall = new Date('2026-08-23T00:00:00.000Z');
|
||||
const result = await auctionBidder.bid(
|
||||
{
|
||||
type: 'auctionBid',
|
||||
@@ -114,8 +115,9 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
auctionId: 31,
|
||||
generalId: general.id,
|
||||
amount,
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
processingGameTick: 100,
|
||||
requestedAtWall,
|
||||
} as any,
|
||||
commandDb as any
|
||||
);
|
||||
await auctionBidder.close();
|
||||
@@ -125,7 +127,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
);
|
||||
const insert = statements.find((query) => query.strings.join(' ').includes('INSERT INTO auction_bid'));
|
||||
const update = statements.find((query) => query.strings.join(' ').includes('UPDATE auction'));
|
||||
return { acceptedAt, processingAt, result, insert, update };
|
||||
return { acceptedAt, processingAt, requestedAtWall, result, insert, update };
|
||||
};
|
||||
|
||||
describe('resource auction Ref compatibility', () => {
|
||||
@@ -135,21 +137,19 @@ describe('resource auction Ref compatibility', () => {
|
||||
|
||||
expect(hasAuctionClosePassed(auction, closeAt, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionClosePassed(auction, new Date(closeAt.getTime() + 1), 72_000_001)).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(false);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, new Date(closeAt.getTime() + 1), null)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the durable API acceptance tick when queue processing crosses the close boundary', () => {
|
||||
it('uses only the authoritative daemon processing tick at the close boundary', () => {
|
||||
const closeAt = new Date('0190-02-01T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
const world = {
|
||||
dateToGameTick: () => 72_000_001,
|
||||
gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)),
|
||||
};
|
||||
const processingNow = new Date(closeAt.getTime() + 1);
|
||||
|
||||
expect(hasAuctionBidClosePassed(auction, world, processingNow, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionBidClosePassed(auction, world, processingNow)).toBe(true);
|
||||
expect(hasAuctionBidClosePassed(auction, world, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionBidClosePassed(auction, world, 72_000_001)).toBe(true);
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'auction-bid-accepted-tick',
|
||||
@@ -161,23 +161,26 @@ describe('resource auction Ref compatibility', () => {
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
acceptedGameTick: 72_000_000,
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
).toMatchObject({ acceptedGameTick: 72_000_000 });
|
||||
).not.toHaveProperty('acceptedGameTick');
|
||||
});
|
||||
|
||||
it('uses the accepted logical time for delayed extension and persisted bid timestamps', async () => {
|
||||
const { acceptedAt, processingAt, result, insert, update } = await runDelayedResourceBid(false);
|
||||
const { acceptedAt, processingAt, requestedAtWall, result, insert, update } =
|
||||
await runDelayedResourceBid(false);
|
||||
|
||||
expect(result).toMatchObject({ type: 'auctionBid', ok: true });
|
||||
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
|
||||
acceptedAt.getTime() + 100_000
|
||||
);
|
||||
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([acceptedAt]);
|
||||
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([
|
||||
acceptedAt,
|
||||
requestedAtWall,
|
||||
]);
|
||||
expect(update?.values.filter((value): value is Date => value instanceof Date)).toEqual([
|
||||
new Date(acceptedAt.getTime() + 100_000),
|
||||
acceptedAt,
|
||||
acceptedAt,
|
||||
]);
|
||||
expect(update?.values).not.toContain(processingAt);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,12 @@ import {
|
||||
import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js';
|
||||
import type { TurnGeneral } from '../src/turn/types.js';
|
||||
|
||||
const withDaemonBoundary = <T extends object>(command: T, processingGameTick = 72_000_000): T =>
|
||||
Object.assign(command, {
|
||||
processingGameTick,
|
||||
requestedAtWall: new Date('2026-09-03T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
describe('unique auction inheritance log compatibility', () => {
|
||||
it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => {
|
||||
const userId = '4c2f2f6d-8a37-4f22-a4f9-1a6f5e4c22ec';
|
||||
@@ -113,19 +119,20 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
}),
|
||||
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
dateToGameTick: (date: Date) => Math.floor(date.getTime() / 1_000),
|
||||
gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
|
||||
pushLog: () => {},
|
||||
};
|
||||
|
||||
const result = await openAuction(
|
||||
{
|
||||
withDaemonBoundary({
|
||||
type: 'auctionOpen',
|
||||
userId: 'user-7',
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
generalId: general.id,
|
||||
amount: 6_000,
|
||||
itemKey: 'che_무기_12_칠성검',
|
||||
},
|
||||
}),
|
||||
world as unknown as Parameters<typeof openAuction>[1],
|
||||
db as unknown as NonNullable<Parameters<typeof openAuction>[2]>
|
||||
);
|
||||
@@ -192,6 +199,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => 72_000_000,
|
||||
gameTickToDate: () => closeAt,
|
||||
pushLog: vi.fn(),
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
@@ -201,12 +209,12 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{
|
||||
withDaemonBoundary({
|
||||
type: 'auctionFinalize',
|
||||
auctionId: 31,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
}),
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 });
|
||||
@@ -241,6 +249,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => nowTick,
|
||||
gameTickToDate: () => closeAt,
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
@@ -249,11 +258,23 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const db = commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>;
|
||||
|
||||
await expect(
|
||||
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }, db)
|
||||
finalizer.finalize(
|
||||
withDaemonBoundary(
|
||||
{ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 },
|
||||
71_999_999
|
||||
),
|
||||
db
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, reason: '경매 마감 시각이 아직 지나지 않았습니다.' });
|
||||
nowTick = 72_000_000;
|
||||
await expect(
|
||||
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 }, db)
|
||||
finalizer.finalize(
|
||||
withDaemonBoundary(
|
||||
{ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 },
|
||||
72_000_000
|
||||
),
|
||||
db
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, reason: '경매 마감 세대가 변경되었습니다.' });
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
|
||||
@@ -276,12 +297,16 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
detail: { amount: 100 },
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
closeTick: null,
|
||||
closeTick: 72_000_000n,
|
||||
},
|
||||
];
|
||||
});
|
||||
const commandDb = { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 0) };
|
||||
const world = { getGameNow: () => closeAt, dateToGameTick: () => 72_000_000 };
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => 72_000_000,
|
||||
gameTickToDate: () => closeAt,
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
@@ -289,7 +314,12 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{ type: 'auctionFinalize', auctionId: 31, expectedCloseAt: closeAt.toISOString() },
|
||||
withDaemonBoundary({
|
||||
type: 'auctionFinalize',
|
||||
auctionId: 31,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
}),
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31');
|
||||
@@ -317,6 +347,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
const queueMessage = vi.fn();
|
||||
const world = {
|
||||
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
gameTickToDate: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
getGeneralById: (id: number) => (id === bidder.id ? bidder : id === host.id ? host : null),
|
||||
getNationById: () => ({ name: '촉', color: '#ff0000' }),
|
||||
updateGeneral,
|
||||
@@ -350,7 +381,7 @@ describe('unique auction inheritance log compatibility', () => {
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{ type: 'auctionFinalize', auctionId: 31 },
|
||||
withDaemonBoundary({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }),
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
|
||||
@@ -79,6 +79,7 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
revision: 1,
|
||||
});
|
||||
const generalTicks = [initialTick + 1_234, initialTick + 36_000_123];
|
||||
const reselectionTick = initialTick + 54_000_456;
|
||||
const auctionCloseTick = initialTick + 72_000_777;
|
||||
const messageOccurrenceTick = initialTick - 500;
|
||||
const messageExpiryTick = initialTick + 90_000_999;
|
||||
@@ -116,6 +117,14 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
turnTime: clock.tickToDate(turnTick),
|
||||
recentWarTick: BigInt(initialTick - 100 - index),
|
||||
recentWarTime: clock.tickToDate(initialTick - 100 - index),
|
||||
meta:
|
||||
index === 0
|
||||
? {
|
||||
next_change_tick: reselectionTick,
|
||||
next_change: clock.tickToDate(reselectionTick).toISOString(),
|
||||
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
|
||||
}
|
||||
: {},
|
||||
})),
|
||||
});
|
||||
await db.auction.create({
|
||||
@@ -138,7 +147,20 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
timeTick: BigInt(messageOccurrenceTick),
|
||||
validUntil: clock.tickToDate(messageExpiryTick),
|
||||
validUntilTick: BigInt(messageExpiryTick),
|
||||
createdAtWall: new Date('2026-01-01T12:34:56.789Z'),
|
||||
deleteUntilWall: new Date('2026-01-01T12:39:56.789Z'),
|
||||
occurredGameTick: BigInt(messageOccurrenceTick),
|
||||
message: {},
|
||||
action: {
|
||||
create: {
|
||||
actionType: 'scout',
|
||||
status: 'PENDING',
|
||||
createdGameTick: BigInt(messageOccurrenceTick),
|
||||
expiresGameTick: BigInt(messageExpiryTick),
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 7n,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.votePoll.create({
|
||||
@@ -201,17 +223,19 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
alignedTick: 236_035_000,
|
||||
});
|
||||
|
||||
const [afterWorld, generals, auction, message, vote, pool, token, ledger, outboxes] = await Promise.all([
|
||||
const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
|
||||
await Promise.all([
|
||||
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
|
||||
db.general.findMany({ orderBy: { id: 'asc' } }),
|
||||
db.auction.findFirstOrThrow(),
|
||||
db.message.findFirstOrThrow(),
|
||||
db.messageAction.findFirstOrThrow(),
|
||||
db.votePoll.findFirstOrThrow(),
|
||||
db.selectPoolEntry.findFirstOrThrow(),
|
||||
db.npcSelectionToken.findFirstOrThrow(),
|
||||
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
|
||||
db.clockProjectionOutbox.findMany(),
|
||||
]);
|
||||
]);
|
||||
const alignedTick = BigInt(reconciled.alignedTick);
|
||||
expect(afterWorld).toMatchObject({
|
||||
clockPhase: 'RECONCILING',
|
||||
@@ -223,8 +247,19 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
expect(generals.map((general) => general.turnTick! - alignedTick)).toEqual(
|
||||
generalTicks.map((tick) => BigInt(tick - initialTick))
|
||||
);
|
||||
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
|
||||
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
|
||||
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
|
||||
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
|
||||
);
|
||||
expect(auction.closeTick! - alignedTick).toBe(BigInt(auctionCloseTick - initialTick));
|
||||
expect(message.validUntilTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick));
|
||||
expect(messageAction.expiresGameTick! - alignedTick).toBe(BigInt(messageExpiryTick - initialTick));
|
||||
expect(messageAction.createdGameTick).toBe(BigInt(messageOccurrenceTick));
|
||||
expect(messageAction.clockRevision).toBe(2n);
|
||||
expect(messageAction.deadlineGeneration).toBe(8n);
|
||||
expect(message.createdAtWall).toEqual(new Date('2026-01-01T12:34:56.789Z'));
|
||||
expect(message.deleteUntilWall).toEqual(new Date('2026-01-01T12:39:56.789Z'));
|
||||
expect(vote.endTick! - alignedTick).toBe(BigInt(voteEndTick - initialTick));
|
||||
expect(pool.reservedUntilTick! - alignedTick).toBe(BigInt(poolTick - initialTick));
|
||||
expect(token.validUntilTick! - alignedTick).toBe(BigInt(npcValidTick - initialTick));
|
||||
@@ -302,6 +337,21 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
await db.general.create({
|
||||
data: { id: 1, name: 'day-general', turnTick: BigInt(turnTick), turnTime: clock.tickToDate(turnTick) },
|
||||
});
|
||||
const wallMessageCreatedAt = new Date('2026-01-15T12:00:00.000Z');
|
||||
const wallMessageDeleteUntil = new Date('2026-01-15T12:05:00.000Z');
|
||||
const wallMessage = await db.message.create({
|
||||
data: {
|
||||
mailbox: 0,
|
||||
type: 'public',
|
||||
src: 1,
|
||||
dest: 0,
|
||||
time: wallMessageCreatedAt,
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
createdAtWall: wallMessageCreatedAt,
|
||||
deleteUntilWall: wallMessageDeleteUntil,
|
||||
message: { src: {}, dest: {}, text: 'wall clock survives 24h suspension', option: {} },
|
||||
},
|
||||
});
|
||||
await db.turnDaemonLease.create({
|
||||
data: {
|
||||
profile: 'clock-day-test',
|
||||
@@ -346,6 +396,10 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
});
|
||||
const shifted = await db.general.findUniqueOrThrow({ where: { id: 1 } });
|
||||
expect(shifted.turnTick! - BigInt(reconciled.alignedTick)).toBe(BigInt(turnTick - initialTick));
|
||||
await expect(db.message.findUniqueOrThrow({ where: { id: wallMessage.id } })).resolves.toMatchObject({
|
||||
createdAtWall: wallMessageCreatedAt,
|
||||
deleteUntilWall: wallMessageDeleteUntil,
|
||||
});
|
||||
|
||||
await redis.client.set('sammo:clock-day-test:clock:active-revision', '3');
|
||||
const redisThenCrash = {
|
||||
|
||||
@@ -25,7 +25,26 @@ integration('database command queue', () => {
|
||||
});
|
||||
await db.message.deleteMany({ where: { mailbox: 991_199 } });
|
||||
await db.worldState.deleteMany({
|
||||
where: { scenarioCode: { in: ['queue-clock-test', 'queue-unification-clock-test'] } },
|
||||
where: { scenarioCode: { in: ['queue-clock-base', 'queue-clock-test', 'queue-unification-clock-test'] } },
|
||||
});
|
||||
};
|
||||
|
||||
const createClockFixture = async (): Promise<void> => {
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'queue-clock-base',
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: new Date('0180-01-01T00:00:00.000Z'),
|
||||
clockTick: 123n,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'),
|
||||
lastTurnTick: 123n,
|
||||
clockPhase: 'MANUAL',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -39,7 +58,10 @@ integration('database command queue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(cleanupFixtures);
|
||||
beforeEach(async () => {
|
||||
await cleanupFixtures();
|
||||
await createClockFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupFixtures();
|
||||
@@ -63,7 +85,16 @@ integration('database command queue', () => {
|
||||
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
|
||||
const commands = firstCommands.concat(secondCommands);
|
||||
|
||||
expect(commands).toEqual([{ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }]);
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
|
||||
|
||||
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||
@@ -118,7 +149,16 @@ integration('database command queue', () => {
|
||||
await queue.initialize();
|
||||
const commands = await queue.drain();
|
||||
|
||||
expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, userId: 'user-8', generalId: 8 }]);
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
type: 'vacation',
|
||||
requestId: expiredId,
|
||||
userId: 'user-8',
|
||||
generalId: 8,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
lockedBy: 'active-worker',
|
||||
@@ -146,7 +186,14 @@ integration('database command queue', () => {
|
||||
const stale = new DatabaseTurnDaemonCommandQueue(db);
|
||||
for (const attempt of [1, 2, 3]) {
|
||||
await expect(owner.drain()).resolves.toEqual([
|
||||
{ type: 'vacation', requestId, userId: 'user-10', generalId: 10 },
|
||||
{
|
||||
type: 'vacation',
|
||||
requestId,
|
||||
userId: 'user-10',
|
||||
generalId: 10,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
await stale.publishCommandError(requestId, new Error('stale worker failure'));
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
@@ -220,7 +267,13 @@ integration('database command queue', () => {
|
||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
const claimed = await owner.drain();
|
||||
expect(claimed).toEqual([command]);
|
||||
expect(claimed).toEqual([
|
||||
{
|
||||
...command,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||
expect(result).toMatchObject({
|
||||
type: 'commandRejected',
|
||||
@@ -301,7 +354,14 @@ integration('database command queue', () => {
|
||||
});
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
|
||||
expect(await queue.drain()).toEqual([{ type: 'getStatus', requestId: statusId }]);
|
||||
expect(await queue.drain()).toEqual([
|
||||
{
|
||||
type: 'getStatus',
|
||||
requestId: statusId,
|
||||
processingGameTick: 100,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
|
||||
status: 'PENDING',
|
||||
processingClockRevision: null,
|
||||
@@ -309,7 +369,14 @@ integration('database command queue', () => {
|
||||
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } });
|
||||
|
||||
expect(await queue.drain()).toEqual([
|
||||
{ type: 'vacation', requestId: gameplayId, userId: 'user-7', generalId: 7 },
|
||||
{
|
||||
type: 'vacation',
|
||||
requestId: gameplayId,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
processingGameTick: 100,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
@@ -347,6 +414,7 @@ integration('database command queue', () => {
|
||||
userId: 'user-8',
|
||||
generalId: 8,
|
||||
processingGameTick: 123,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({
|
||||
@@ -359,6 +427,103 @@ integration('database command queue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('dequeues only tournament bet accounting commands while the game clock is suspended', async () => {
|
||||
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
||||
const world = existingWorld
|
||||
? await db.worldState.update({
|
||||
where: { id: existingWorld.id },
|
||||
data: { clockPhase: 'SUSPENDED', clockRevision: 19n, deadlineGeneration: 6n, clockTick: 321n },
|
||||
})
|
||||
: await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'queue-clock-test',
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
clockPhase: 'SUSPENDED',
|
||||
clockRevision: 19n,
|
||||
deadlineGeneration: 6n,
|
||||
clockTick: 321n,
|
||||
},
|
||||
});
|
||||
const resourceId = 'integration:engine:suspended-tournament-bet-resource';
|
||||
const metaId = 'integration:engine:suspended-tournament-bet-meta';
|
||||
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
|
||||
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
requestId: resourceId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralResources',
|
||||
payload: {
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: resourceId,
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: 7, goldDelta: -100 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: metaId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralMeta',
|
||||
payload: {
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: metaId,
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: 7, metaDelta: { betgold: 100 } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: rollbackId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralResources',
|
||||
payload: {
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: rollbackId,
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: 7, goldDelta: 100 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: unrelatedId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralResources',
|
||||
payload: {
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: unrelatedId,
|
||||
reason: 'otherMutation',
|
||||
adjustments: [{ generalId: 7, goldDelta: -100 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||
await expect(queue.drain()).resolves.toEqual([
|
||||
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
|
||||
expect.objectContaining({ type: 'adjustGeneralMeta', requestId: metaId, reason: 'tournamentBet' }),
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: rollbackId,
|
||||
reason: 'tournamentBetRollback',
|
||||
}),
|
||||
]);
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
processingGameTick: 321n,
|
||||
processingClockRevision: 19n,
|
||||
processingDeadlineGeneration: 6n,
|
||||
});
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: unrelatedId } })).resolves.toMatchObject({
|
||||
status: 'PENDING',
|
||||
processingClockRevision: null,
|
||||
});
|
||||
|
||||
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RECONCILING' } });
|
||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('dequeues only the invader decision while an UNIFICATION_WAIT suspension is active', async () => {
|
||||
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
||||
const world = existingWorld
|
||||
@@ -389,6 +554,37 @@ integration('database command queue', () => {
|
||||
message: { option: { action: 'raiseInvader', used: false } },
|
||||
},
|
||||
});
|
||||
await db.messageAction.create({
|
||||
data: {
|
||||
messageId: message.id,
|
||||
actionType: 'raiseInvader',
|
||||
status: 'PENDING',
|
||||
createdGameTick: 900n,
|
||||
clockRevision: 31n,
|
||||
deadlineGeneration: 7n,
|
||||
},
|
||||
});
|
||||
const scoutMessage = await db.message.create({
|
||||
data: {
|
||||
mailbox: 991_199,
|
||||
type: 'private',
|
||||
src: 7,
|
||||
dest: 991_199,
|
||||
time: new Date(),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
message: { option: { action: 'scout', used: false } },
|
||||
},
|
||||
});
|
||||
await db.messageAction.create({
|
||||
data: {
|
||||
messageId: scoutMessage.id,
|
||||
actionType: 'scout',
|
||||
status: 'PENDING',
|
||||
createdGameTick: 900n,
|
||||
clockRevision: 31n,
|
||||
deadlineGeneration: 7n,
|
||||
},
|
||||
});
|
||||
await db.clockSuspension.create({
|
||||
data: {
|
||||
id: 'integration-unification-wait',
|
||||
@@ -404,6 +600,7 @@ integration('database command queue', () => {
|
||||
},
|
||||
});
|
||||
const messageRequestId = 'integration:engine:unification-message';
|
||||
const scoutRequestId = 'integration:engine:suspended-scout-response';
|
||||
const gameplayRequestId = 'integration:engine:unification-gameplay';
|
||||
await db.inputEvent.createMany({
|
||||
data: [
|
||||
@@ -424,6 +621,23 @@ integration('database command queue', () => {
|
||||
response: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: scoutRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'messageRespond',
|
||||
actorUserId: 'user-991199',
|
||||
acceptedGameTick: 900n,
|
||||
acceptedClockRevision: 31n,
|
||||
acceptedDeadlineGeneration: 7n,
|
||||
payload: {
|
||||
type: 'messageRespond',
|
||||
requestId: scoutRequestId,
|
||||
userId: 'user-991199',
|
||||
generalId: 991_199,
|
||||
messageId: scoutMessage.id,
|
||||
response: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
requestId: gameplayRequestId,
|
||||
target: 'ENGINE',
|
||||
@@ -451,10 +665,15 @@ integration('database command queue', () => {
|
||||
generalId: 991_199,
|
||||
messageId: message.id,
|
||||
response: true,
|
||||
processingGameTick: 900,
|
||||
requestedAtWall: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } })
|
||||
).resolves.toMatchObject({ status: 'PENDING' });
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: scoutRequestId } })).resolves.toMatchObject({
|
||||
status: 'PENDING',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,6 +165,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
db = connector.prisma;
|
||||
disconnect = () => connector.disconnect();
|
||||
await dropFailureConstraints();
|
||||
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||
@@ -198,6 +199,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
await hooks?.close();
|
||||
if (db) {
|
||||
await dropFailureConstraints();
|
||||
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||
@@ -284,7 +286,13 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
const assertStored = async (point: number, spent: number, logCount: number, messageCount: number) => {
|
||||
const assertStored = async (
|
||||
point: number,
|
||||
spent: number,
|
||||
logCount: number,
|
||||
messageCount: number,
|
||||
ledgerCount: number
|
||||
) => {
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: actorUserId, key: 'previous' } },
|
||||
@@ -299,6 +307,9 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
await expect(
|
||||
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
|
||||
).resolves.toBe(messageCount);
|
||||
await expect(
|
||||
db.inheritanceLedger.count({ where: { requestId: { startsWith: requestPrefix } } })
|
||||
).resolves.toBe(ledgerCount);
|
||||
};
|
||||
|
||||
const pointCommand = buildCommand('point', {
|
||||
@@ -315,7 +326,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`);
|
||||
expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 });
|
||||
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff');
|
||||
await assertStored(10_000, 17, 0, 0);
|
||||
await assertStored(10_000, 17, 0, 0, 0);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
|
||||
).resolves.toMatchObject({
|
||||
@@ -324,7 +335,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
});
|
||||
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
|
||||
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 });
|
||||
await assertStored(9_800, 217, 1, 0);
|
||||
await assertStored(9_800, 217, 1, 0, 1);
|
||||
|
||||
const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
|
||||
await createInputEvent(rankCommand);
|
||||
@@ -335,14 +346,14 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
`);
|
||||
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
|
||||
expect(world.peekDirtyState().messages).toEqual([]);
|
||||
await assertStored(9_800, 217, 1, 0);
|
||||
await assertStored(9_800, 217, 1, 0, 1);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
|
||||
await expect(execute(rankCommand)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
remainPoint: 8_800,
|
||||
ownerName: '레거시 소유자',
|
||||
});
|
||||
await assertStored(8_800, 1_217, 2, 2);
|
||||
await assertStored(8_800, 1_217, 2, 2, 2);
|
||||
|
||||
const currentLog = await db.inheritanceLog.findFirstOrThrow({
|
||||
where: { userId: actorUserId },
|
||||
@@ -358,10 +369,10 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
`);
|
||||
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
|
||||
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique');
|
||||
await assertStored(8_800, 1_217, 2, 2);
|
||||
await assertStored(8_800, 1_217, 2, 2, 2);
|
||||
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
|
||||
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
|
||||
await assertStored(5_800, 4_217, 3, 2);
|
||||
await assertStored(5_800, 4_217, 3, 2, 3);
|
||||
|
||||
const freeStatCommand = buildCommand('free-stat', {
|
||||
action: 'resetStat',
|
||||
@@ -372,7 +383,21 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
||||
});
|
||||
await createInputEvent(freeStatCommand);
|
||||
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
|
||||
await assertStored(5_800, 4_217, 5, 2);
|
||||
await assertStored(5_800, 4_217, 5, 2, 4);
|
||||
|
||||
const ledgers = await db.inheritanceLedger.findMany({
|
||||
where: { requestId: { startsWith: requestPrefix } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
expect(ledgers.map(({ action, cost, status }) => ({ action, cost, status }))).toEqual([
|
||||
{ action: 'buyHiddenBuff', cost: 200, status: 'APPLIED' },
|
||||
{ action: 'checkOwner', cost: 1_000, status: 'APPLIED' },
|
||||
{ action: 'buyRandomUnique', cost: 3_000, status: 'APPLIED' },
|
||||
{ action: 'resetStat', cost: 0, status: 'APPLIED' },
|
||||
]);
|
||||
expect(ledgers.every((row) => row.consumedAtWall instanceof Date && row.createdAtWall instanceof Date)).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const messages = await db.message.findMany({
|
||||
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('input event atomicity', () => {
|
||||
ok: true,
|
||||
auctionId: 3,
|
||||
closeAt: '2026-01-01T00:10:00.000Z',
|
||||
closeTick: 3_600_000,
|
||||
};
|
||||
let resolveResponse: (() => void) | undefined;
|
||||
const responded = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -806,6 +806,8 @@ describeDb('scenario database seed', () => {
|
||||
amount: 1,
|
||||
eventId: marker,
|
||||
eventAt: new Date('2033-01-01T00:00:00.000Z'),
|
||||
occurredGameTick: 0n,
|
||||
requestedAtWall: new Date('2033-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
const bettingId = 990_731;
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('selection-pool reservation command state', () => {
|
||||
const rows = buildRows();
|
||||
const world = buildWorld(rows);
|
||||
const db = buildDb(rows);
|
||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
||||
const reserve = (userId: string, processingGameTick: number) =>
|
||||
reserveSelectionPool({
|
||||
db: db as never,
|
||||
world,
|
||||
@@ -232,7 +232,7 @@ describe('selection-pool reservation command state', () => {
|
||||
userId,
|
||||
seedOwnerIdentity: userId,
|
||||
now: acceptedAt,
|
||||
acceptedGameTick,
|
||||
processingGameTick,
|
||||
});
|
||||
|
||||
const first = await reserve('first-user', 0);
|
||||
@@ -267,7 +267,7 @@ describe('selection-pool reservation command state', () => {
|
||||
rows[1]!.reservedUntilTick = 0n;
|
||||
const world = buildWorld(rows);
|
||||
const db = buildDb(rows);
|
||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
||||
const reserve = (userId: string, processingGameTick: number) =>
|
||||
reserveSelectionPool({
|
||||
db: db as never,
|
||||
world,
|
||||
@@ -275,7 +275,7 @@ describe('selection-pool reservation command state', () => {
|
||||
userId,
|
||||
seedOwnerIdentity: userId,
|
||||
now: acceptedAt,
|
||||
acceptedGameTick,
|
||||
processingGameTick,
|
||||
});
|
||||
|
||||
const first = await reserve('first-user', 0);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
@@ -81,38 +80,12 @@ const buildDefaultUniquePoolSnapshot = (general: TurnGeneral): TurnWorldSnapshot
|
||||
});
|
||||
|
||||
describe('voteReward command', () => {
|
||||
it('keeps the wall-time fallback open at exact deadline equality', () => {
|
||||
it('fails closed when a GAME_TIME poll lost its authoritative end tick', () => {
|
||||
const deadline = new Date('0180-01-01T00:00:00.000Z');
|
||||
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, deadline, 0)).toBe(false);
|
||||
expect(
|
||||
hasVotePollDeadlinePassed(
|
||||
{ endAt: deadline, endTick: null, closedAt: null },
|
||||
new Date(deadline.getTime() + 1),
|
||||
0
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves the server-accepted game tick through durable command normalization', () => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'vote-accepted-tick',
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'voteReward',
|
||||
userId: 'user-1',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
type: 'voteReward',
|
||||
requestId: 'vote-accepted-tick',
|
||||
acceptedGameTick: 100,
|
||||
});
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, 0)).toBe(true);
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 0)).toBe(false);
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies gold, unique item, logs, and idempotency', async () => {
|
||||
@@ -290,9 +263,7 @@ describe('voteReward command', () => {
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
// Ref accepts the request at exact equality. Engine processing may
|
||||
// occur after the logical clock has advanced beyond the deadline.
|
||||
acceptedGameTick: 0,
|
||||
processingGameTick: 0,
|
||||
};
|
||||
|
||||
const writerWindowStart = Date.now();
|
||||
@@ -418,29 +389,26 @@ describe('voteReward command', () => {
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld });
|
||||
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command;
|
||||
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
} as any,
|
||||
});
|
||||
expect(legacyLateResult).toMatchObject({
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
reason: '설문조사가 종료되었습니다.',
|
||||
});
|
||||
const { processingGameTick: _processingGameTick, ...missingBoundaryCommand } = command;
|
||||
await expect(
|
||||
legacyLateHandler.handle(missingBoundaryCommand, {
|
||||
db: {
|
||||
...actorBindingDb(),
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
} as any,
|
||||
})
|
||||
).rejects.toThrow('authoritative daemon processing game tick');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -501,8 +469,8 @@ describe('voteReward command', () => {
|
||||
voteId,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
acceptedGameTick,
|
||||
},
|
||||
processingGameTick: acceptedGameTick,
|
||||
} as any,
|
||||
{ db: commandDb as any }
|
||||
);
|
||||
|
||||
@@ -602,7 +570,8 @@ describe('voteReward command', () => {
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
},
|
||||
processingGameTick: 0,
|
||||
} as any,
|
||||
{ db: commandDb as any }
|
||||
);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export const createGameServerActivityTracker = (): GameServerActivityTracker =>
|
||||
|
||||
return {
|
||||
lastContactAt: readonly(lastContactAt),
|
||||
markContact(contactAt = Date.now()) {
|
||||
markContact(contactAt = performance.now()) {
|
||||
if (!Number.isFinite(contactAt)) return;
|
||||
lastContactAt.value = contactAt;
|
||||
},
|
||||
@@ -21,7 +21,7 @@ export const createGameServerActivityTracker = (): GameServerActivityTracker =>
|
||||
|
||||
export const isRecentGameServerActivity = (
|
||||
lastContactAt: number | null,
|
||||
now = Date.now(),
|
||||
now = performance.now(),
|
||||
freshnessMs = GAME_SERVER_ACTIVITY_FRESHNESS_MS
|
||||
): boolean =>
|
||||
lastContactAt !== null &&
|
||||
@@ -31,4 +31,4 @@ export const isRecentGameServerActivity = (
|
||||
|
||||
export const gameServerActivity = createGameServerActivityTracker();
|
||||
|
||||
export const markGameServerContact = (contactAt = Date.now()) => gameServerActivity.markContact(contactAt);
|
||||
export const markGameServerContact = (contactAt = performance.now()) => gameServerActivity.markContact(contactAt);
|
||||
|
||||
@@ -1260,7 +1260,7 @@ export const adminRouter = router({
|
||||
if (!canReadProfile(adminAuth, initialOperation.profileName)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' });
|
||||
}
|
||||
const deadline = Date.now() + input.timeoutMs;
|
||||
const deadline = performance.now() + input.timeoutMs;
|
||||
while (true) {
|
||||
const [operation, entries] = await Promise.all([
|
||||
ctx.profiles.getOperation(input.id),
|
||||
@@ -1270,7 +1270,7 @@ export const adminRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' });
|
||||
}
|
||||
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
|
||||
if (entries.length || terminal || Date.now() >= deadline) {
|
||||
if (entries.length || terminal || performance.now() >= deadline) {
|
||||
return {
|
||||
operation,
|
||||
entries,
|
||||
@@ -1942,7 +1942,7 @@ export const adminRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const deadline = Date.now() + input.timeoutMs;
|
||||
const deadline = performance.now() + input.timeoutMs;
|
||||
while (true) {
|
||||
const [operation, entries] = await Promise.all([
|
||||
ctx.releases.getOperation(input.id),
|
||||
@@ -1952,7 +1952,7 @@ export const adminRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Gateway release operation not found.' });
|
||||
}
|
||||
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
|
||||
if (entries.length || terminal || Date.now() >= deadline) {
|
||||
if (entries.length || terminal || performance.now() >= deadline) {
|
||||
return {
|
||||
operation,
|
||||
entries,
|
||||
@@ -2519,8 +2519,8 @@ export const adminRouter = router({
|
||||
reason: input.reason,
|
||||
requestedBy: adminAuth.user.id,
|
||||
});
|
||||
const deadline = Date.now() + 10 * 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + 10 * 60_000;
|
||||
while (performance.now() < deadline) {
|
||||
await ctx.orchestrator.runOperationsNow();
|
||||
const current = await ctx.profiles.getOperation(operation.id);
|
||||
if (current?.status === 'SUCCEEDED') {
|
||||
|
||||
@@ -2995,7 +2995,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
profile: GatewayProfileRecord,
|
||||
assertLease?: () => Promise<void>
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + this.profileReadinessTimeoutMs;
|
||||
const deadline = performance.now() + this.profileReadinessTimeoutMs;
|
||||
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
||||
const expectedNames = Object.entries(definitions)
|
||||
.filter(([role]) => this.frontendServeMode === 'preview' || role !== 'frontend')
|
||||
@@ -3008,7 +3008,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.processConfig.frontendReadinessOrigin ?? 'http://caddy'
|
||||
).toString()
|
||||
: `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`;
|
||||
while (Date.now() < deadline) {
|
||||
while (performance.now() < deadline) {
|
||||
await assertLease?.();
|
||||
try {
|
||||
const [api, frontend, processes] = await Promise.all([
|
||||
|
||||
@@ -242,13 +242,19 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
|
||||
});
|
||||
return mapOperation(row);
|
||||
},
|
||||
async claimNextOperation(now, lease) {
|
||||
async claimNextOperation(_now, lease) {
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw<Array<{ lock_result: string }>>`
|
||||
SELECT pg_advisory_xact_lock(
|
||||
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
|
||||
)::text AS lock_result
|
||||
`;
|
||||
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`
|
||||
SELECT CURRENT_TIMESTAMP AS "now"
|
||||
`;
|
||||
if (!now) {
|
||||
throw new Error('Database wall clock is unavailable while claiming a Gateway release operation.');
|
||||
}
|
||||
const runningProfileOperation = await tx.gatewayOperation.findFirst({
|
||||
where: { status: 'RUNNING' },
|
||||
select: { id: true },
|
||||
@@ -326,13 +332,21 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
|
||||
});
|
||||
return row ? mapOperation(row) : null;
|
||||
},
|
||||
async renewOperationLease(id, ownerId, now, durationMs) {
|
||||
const updated = await prisma.gatewayReleaseOperation.updateMany({
|
||||
where: { id, status: 'RUNNING', leaseOwner: ownerId },
|
||||
data: {
|
||||
leaseUntil: new Date(now.getTime() + durationMs),
|
||||
heartbeatAt: now,
|
||||
},
|
||||
async renewOperationLease(id, ownerId, _now, durationMs) {
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`
|
||||
SELECT CURRENT_TIMESTAMP AS "now"
|
||||
`;
|
||||
if (!now) {
|
||||
throw new Error('Database wall clock is unavailable while renewing a Gateway release lease.');
|
||||
}
|
||||
return tx.gatewayReleaseOperation.updateMany({
|
||||
where: { id, status: 'RUNNING', leaseOwner: ownerId },
|
||||
data: {
|
||||
leaseUntil: new Date(now.getTime() + durationMs),
|
||||
heartbeatAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
return updated.count === 1;
|
||||
},
|
||||
|
||||
@@ -691,7 +691,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
return mapOperation(row);
|
||||
},
|
||||
async claimNextOperation(
|
||||
now: Date,
|
||||
_now: Date,
|
||||
lease?: { ownerId: string; durationMs: number }
|
||||
): Promise<GatewayOperationRecord | null> {
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
@@ -700,6 +700,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
|
||||
)::text AS lock_result
|
||||
`;
|
||||
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`
|
||||
SELECT CURRENT_TIMESTAMP AS "now"
|
||||
`;
|
||||
if (!now) {
|
||||
throw new Error('Database wall clock is unavailable while claiming a Gateway operation.');
|
||||
}
|
||||
const runningRelease = await tx.gatewayReleaseOperation.findFirst({
|
||||
where: { status: 'RUNNING' },
|
||||
select: { id: true },
|
||||
@@ -815,13 +821,21 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
});
|
||||
return row ? mapOperation(row) : null;
|
||||
},
|
||||
async renewOperationLease(id: string, ownerId: string, now: Date, durationMs: number): Promise<boolean> {
|
||||
const renewed = await prisma.gatewayOperation.updateMany({
|
||||
where: { id, status: 'RUNNING', leaseOwner: ownerId },
|
||||
data: {
|
||||
leaseUntil: new Date(now.getTime() + durationMs),
|
||||
heartbeatAt: now,
|
||||
},
|
||||
async renewOperationLease(id: string, ownerId: string, _now: Date, durationMs: number): Promise<boolean> {
|
||||
const renewed = await prisma.$transaction(async (tx) => {
|
||||
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`
|
||||
SELECT CURRENT_TIMESTAMP AS "now"
|
||||
`;
|
||||
if (!now) {
|
||||
throw new Error('Database wall clock is unavailable while renewing a Gateway operation lease.');
|
||||
}
|
||||
return tx.gatewayOperation.updateMany({
|
||||
where: { id, status: 'RUNNING', leaseOwner: ownerId },
|
||||
data: {
|
||||
leaseUntil: new Date(now.getTime() + durationMs),
|
||||
heartbeatAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
return renewed.count === 1;
|
||||
},
|
||||
|
||||
@@ -293,13 +293,13 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null })
|
||||
}
|
||||
if (!gitRef) {
|
||||
const cached = previewCache.get(DEFAULT_CACHE_KEY);
|
||||
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
|
||||
if (cached && performance.now() - cached.loadedAt < CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
const ids = await listScenarioIds();
|
||||
const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id)));
|
||||
previewCache.set(DEFAULT_CACHE_KEY, {
|
||||
loadedAt: Date.now(),
|
||||
loadedAt: performance.now(),
|
||||
data: previews,
|
||||
});
|
||||
return previews;
|
||||
@@ -308,7 +308,7 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null })
|
||||
const commitSha = await resolveGitCommitSha(gitRef);
|
||||
const cacheKey = commitSha;
|
||||
const cached = previewCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
|
||||
if (cached && performance.now() - cached.loadedAt < CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
const ids = await listScenarioIdsFromGit(commitSha);
|
||||
@@ -317,7 +317,7 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null })
|
||||
previews.push(await buildScenarioPreviewFromGit(commitSha, id));
|
||||
}
|
||||
previewCache.set(cacheKey, {
|
||||
loadedAt: Date.now(),
|
||||
loadedAt: performance.now(),
|
||||
data: previews,
|
||||
});
|
||||
return previews;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import {
|
||||
WEB_PUSH_EVENT_TYPES,
|
||||
@@ -409,10 +410,13 @@ export class WebPushCoordinator {
|
||||
`);
|
||||
if (rows.length === 0) return [];
|
||||
const ids = rows.map((row) => row.id);
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
||||
});
|
||||
await tx.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery
|
||||
SET locked_at = CURRENT_TIMESTAMP,
|
||||
lock_owner = ${this.owner},
|
||||
attempts = attempts + 1
|
||||
WHERE id IN (${GatewayPrisma.join(ids)})
|
||||
`);
|
||||
return tx.webPushDelivery.findMany({
|
||||
where: { id: { in: ids }, lockOwner: this.owner },
|
||||
include: { notification: true, subscription: true },
|
||||
@@ -421,22 +425,31 @@ export class WebPushCoordinator {
|
||||
});
|
||||
|
||||
for (const delivery of claimed) {
|
||||
if (delivery.subscription.expirationTime && delivery.subscription.expirationTime.getTime() <= Date.now()) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: 'Push subscription expired.',
|
||||
},
|
||||
});
|
||||
await tx.webPushSubscription.update({
|
||||
where: { id: delivery.subscriptionId },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
});
|
||||
const expired = await this.prisma.$transaction(async (tx) => {
|
||||
const count = await tx.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery AS delivery
|
||||
SET status = 'FAILED'::"WebPushDeliveryStatus",
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
last_error = 'Push subscription expired.'
|
||||
FROM web_push_subscription AS subscription
|
||||
WHERE delivery.id = ${delivery.id}
|
||||
AND delivery.lock_owner = ${this.owner}
|
||||
AND subscription.id = delivery.subscription_id
|
||||
AND subscription.expiration_time IS NOT NULL
|
||||
AND subscription.expiration_time <= CURRENT_TIMESTAMP
|
||||
`);
|
||||
if (count > 0) {
|
||||
await tx.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_subscription
|
||||
SET disabled_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ${delivery.subscriptionId}
|
||||
`);
|
||||
}
|
||||
return count > 0;
|
||||
});
|
||||
if (expired) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -453,16 +466,15 @@ export class WebPushCoordinator {
|
||||
}),
|
||||
{ TTL: 60 * 60 }
|
||||
);
|
||||
await this.prisma.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
data: {
|
||||
status: 'DELIVERED',
|
||||
deliveredAt: new Date(),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
await this.prisma.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery
|
||||
SET status = 'DELIVERED'::"WebPushDeliveryStatus",
|
||||
delivered_at = CURRENT_TIMESTAMP,
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
last_error = NULL
|
||||
WHERE id = ${delivery.id} AND lock_owner = ${this.owner}
|
||||
`);
|
||||
} catch (error) {
|
||||
const statusCode =
|
||||
typeof error === 'object' && error !== null && 'statusCode' in error
|
||||
@@ -478,28 +490,31 @@ export class WebPushCoordinator {
|
||||
const safeError =
|
||||
statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
data: {
|
||||
status: terminal || exhausted ? 'FAILED' : 'PENDING',
|
||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: safeError,
|
||||
},
|
||||
});
|
||||
const nextStatus = terminal || exhausted ? 'FAILED' : 'PENDING';
|
||||
await tx.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery
|
||||
SET status = ${nextStatus}::"WebPushDeliveryStatus",
|
||||
available_at = CURRENT_TIMESTAMP
|
||||
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
last_error = ${safeError}
|
||||
WHERE id = ${delivery.id} AND lock_owner = ${this.owner}
|
||||
`);
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
await tx.webPushSubscription.update({
|
||||
where: { id: delivery.subscriptionId },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
await tx.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_subscription
|
||||
SET disabled_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ${delivery.subscriptionId}
|
||||
`);
|
||||
}
|
||||
});
|
||||
if (!terminal) this.onError(new Error(safeError));
|
||||
}
|
||||
}
|
||||
if (Date.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = Date.now() + 60_000;
|
||||
if (performance.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = performance.now() + 60_000;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw(GatewayPrisma.sql`
|
||||
WITH expired AS (
|
||||
@@ -534,7 +549,7 @@ export class WebPushCoordinator {
|
||||
|
||||
private run(): void {
|
||||
if (!this.configured || this.inFlight) return;
|
||||
const now = Date.now();
|
||||
const now = performance.now();
|
||||
const shouldReconcileProfiles = now >= this.nextProfileReconcileAt;
|
||||
if (shouldReconcileProfiles) this.nextProfileReconcileAt = now + 5_000;
|
||||
this.inFlight = (shouldReconcileProfiles ? this.reconcileProfiles() : Promise.resolve())
|
||||
|
||||
@@ -41,13 +41,16 @@ describeDatabase('gateway release operation persistence', () => {
|
||||
).rejects.toMatchObject({ code: 'P2002' });
|
||||
|
||||
const now = new Date('2030-01-01T00:00:00.000Z');
|
||||
await expect(
|
||||
repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 })
|
||||
).resolves.toMatchObject({
|
||||
const claimed = await repository.claimNextOperation(now, {
|
||||
ownerId: 'controller-a',
|
||||
durationMs: 1_000,
|
||||
});
|
||||
expect(claimed).toMatchObject({
|
||||
id: operation.id,
|
||||
attempts: 1,
|
||||
leaseOwner: 'controller-a',
|
||||
});
|
||||
expect(Date.parse(claimed?.leaseUntil ?? '')).toBeLessThan(Date.now() + 5_000);
|
||||
await expect(repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'a'.repeat(40))).resolves.toBe(
|
||||
true
|
||||
);
|
||||
@@ -103,6 +106,11 @@ describeDatabase('gateway release operation persistence', () => {
|
||||
await repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 });
|
||||
await repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'c'.repeat(40));
|
||||
|
||||
await connector.prisma.$executeRaw`
|
||||
UPDATE "gateway_release_operation"
|
||||
SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second'
|
||||
WHERE "id" = ${operation.id}
|
||||
`;
|
||||
await expect(
|
||||
repository.claimNextOperation(new Date(now.getTime() + 1_001), {
|
||||
ownerId: 'controller-b',
|
||||
|
||||
@@ -114,6 +114,11 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
||||
await expect(
|
||||
repository.claimNextOperation(beforeReset, { ownerId: 'worker-b', durationMs: 1_000 })
|
||||
).resolves.toBeNull();
|
||||
await connector.prisma.$executeRaw`
|
||||
UPDATE "gateway_operation"
|
||||
SET "scheduled_at" = CURRENT_TIMESTAMP - INTERVAL '1 second'
|
||||
WHERE "id" = ${scheduledReset.id}
|
||||
`;
|
||||
await expect(
|
||||
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-b', durationMs: 1_000 })
|
||||
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
|
||||
@@ -168,13 +173,18 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
||||
sourceRef: 'b'.repeat(40),
|
||||
requestedBy: 'deploy-admin',
|
||||
});
|
||||
await connector.prisma.$executeRaw`
|
||||
UPDATE "gateway_operation"
|
||||
SET "scheduled_at" = CURRENT_TIMESTAMP - INTERVAL '1 second'
|
||||
WHERE "id" = ${scheduledReset.id}
|
||||
`;
|
||||
|
||||
await expect(
|
||||
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-a', durationMs: 1_000 })
|
||||
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
|
||||
await expect(repository.getOperation(interimDeploy.id)).resolves.toMatchObject({
|
||||
status: 'CANCELLED',
|
||||
completedAt: scheduledAt.toISOString(),
|
||||
completedAt: expect.any(String),
|
||||
});
|
||||
await expect(repository.listOperationLogs(interimDeploy.id)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -405,6 +415,11 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
||||
durationMs: 1_000,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
await connector.prisma.$executeRaw`
|
||||
UPDATE "gateway_operation"
|
||||
SET "lease_until" = CURRENT_TIMESTAMP - INTERVAL '1 second'
|
||||
WHERE "id" = ${operation.id}
|
||||
`;
|
||||
const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), {
|
||||
ownerId: 'worker-b',
|
||||
durationMs: 1_000,
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
|
||||
gameSchemaHead: '20260903103000_add_input_event_clock_processing',
|
||||
gameSchemaHead: '20260903140000_split_message_wall_and_game_time',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ let cachedAt = 0;
|
||||
let inFlight: Promise<AdminProfileNavigationItem[]> | undefined;
|
||||
|
||||
export const loadAdminProfileNavigation = async (): Promise<AdminProfileNavigationItem[]> => {
|
||||
if (cachedProfiles && Date.now() - cachedAt < 5_000) return cachedProfiles;
|
||||
if (cachedProfiles && performance.now() - cachedAt < 5_000) return cachedProfiles;
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = directTrpc.admin.profiles.listNavigation
|
||||
.query()
|
||||
.then((profiles) => {
|
||||
cachedProfiles = profiles as AdminProfileNavigationItem[];
|
||||
cachedAt = Date.now();
|
||||
cachedAt = performance.now();
|
||||
return cachedProfiles;
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -85,7 +85,7 @@ const main = async (): Promise<void> => {
|
||||
process.once('SIGINT', () => void stop());
|
||||
process.once('SIGTERM', () => void stop());
|
||||
while (!stopping) {
|
||||
const now = Date.now();
|
||||
const now = performance.now();
|
||||
if (now >= nextWorkspaceCleanupAt) {
|
||||
nextWorkspaceCleanupAt = now + RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS;
|
||||
try {
|
||||
|
||||
@@ -510,7 +510,7 @@ export class GatewayReleaseController {
|
||||
|
||||
private async waitForReadiness(operationId: string): Promise<void> {
|
||||
await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
|
||||
const deadline = Date.now() + this.config.readinessTimeoutMs;
|
||||
const deadline = performance.now() + this.config.readinessTimeoutMs;
|
||||
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
|
||||
const frontendUrl =
|
||||
this.config.frontendServeMode === 'static'
|
||||
@@ -522,7 +522,7 @@ export class GatewayReleaseController {
|
||||
const expectedNames = buildGatewayProcessDefinitions(this.config.workspaceRoot, this.config).map(
|
||||
(definition) => definition.name
|
||||
);
|
||||
while (Date.now() < deadline) {
|
||||
while (performance.now() < deadline) {
|
||||
try {
|
||||
const [api, frontend] = await Promise.all([
|
||||
this.fetchImpl(apiUrl),
|
||||
|
||||
@@ -92,8 +92,8 @@ export const upgradeReleaseController = async (options: {
|
||||
}
|
||||
try {
|
||||
await options.processManager.start(buildReleaseControllerDefinition(workspace.root, options.config));
|
||||
const deadline = Date.now() + (options.readinessTimeoutMs ?? options.config.readinessTimeoutMs);
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + (options.readinessTimeoutMs ?? options.config.readinessTimeoutMs);
|
||||
while (performance.now() < deadline) {
|
||||
const matching = (await options.processManager.list()).filter(
|
||||
(process) => process.name === CONTROLLER_PROCESS_NAME
|
||||
);
|
||||
|
||||
@@ -16,13 +16,23 @@
|
||||
"world_state.deadline_generation",
|
||||
"general.turn_tick",
|
||||
"general.recent_war_tick",
|
||||
"general.meta.next_change_tick",
|
||||
"select_pool.reserved_until_tick",
|
||||
"select_npc_token.valid_until_tick",
|
||||
"select_npc_token.pick_more_from_tick",
|
||||
"message.time_tick",
|
||||
"message.valid_until_tick",
|
||||
"message.occurred_game_tick",
|
||||
"message_action.created_game_tick",
|
||||
"message_action.expires_game_tick",
|
||||
"message_action.resolved_game_tick",
|
||||
"message_action.clock_revision",
|
||||
"message_action.deadline_generation",
|
||||
"inheritance_ledger.applied_clock_revision",
|
||||
"inheritance_ledger.applied_deadline_generation",
|
||||
"auction.open_tick",
|
||||
"auction.close_tick",
|
||||
"auction_bid.occurred_game_tick",
|
||||
"vote_poll.start_tick",
|
||||
"vote_poll.end_tick",
|
||||
"clock_suspension.source_revision",
|
||||
@@ -34,6 +44,43 @@
|
||||
"clock_suspension.aligned_tick",
|
||||
"clock_projection_outbox.target_revision"
|
||||
],
|
||||
"wallTimeFields": [
|
||||
"input_event.created_at",
|
||||
"input_event.processing_at",
|
||||
"input_event.completed_at",
|
||||
"input_event.lease_until",
|
||||
"read_model_outbox.available_at",
|
||||
"read_model_outbox.locked_at",
|
||||
"read_model_outbox.delivered_at",
|
||||
"web_push_outbox.available_at",
|
||||
"web_push_outbox.locked_at",
|
||||
"web_push_outbox.delivered_at",
|
||||
"turn_daemon_lease.lease_until",
|
||||
"turn_daemon_lease.heartbeat_at",
|
||||
"message.created_at_wall",
|
||||
"message.delete_until_wall",
|
||||
"message.tombstoned_at_wall",
|
||||
"message_read_state.updated_at",
|
||||
"diplomacy_letter.date",
|
||||
"auction_bid.requested_at_wall",
|
||||
"auction_bid.created_at",
|
||||
"auction.finalizing_at",
|
||||
"auction.finished_at",
|
||||
"inheritance_ledger.requested_at_wall",
|
||||
"inheritance_ledger.consumed_at_wall",
|
||||
"inheritance_ledger.created_at_wall",
|
||||
"clock_suspension.cut_wall_at",
|
||||
"clock_suspension.resume_wall_at"
|
||||
],
|
||||
"excludedFromReconciliation": [
|
||||
"all WALL_TIME created_at and updated_at audit fields",
|
||||
"normal message envelope and five-minute deletion lifecycle",
|
||||
"account and inheritance receipt timestamps",
|
||||
"traffic and general-access periods",
|
||||
"notification and outbox delivery/retry timestamps",
|
||||
"daemon, worker, editor, gateway, and release leases",
|
||||
"board, authentication, account, audit, and operator timestamps"
|
||||
],
|
||||
"participants": [
|
||||
{
|
||||
"key": "world-clock",
|
||||
@@ -56,6 +103,13 @@
|
||||
"projectionFields": ["general.turn_time"],
|
||||
"owner": "game-engine/turn-daemon"
|
||||
},
|
||||
{
|
||||
"key": "selection-reselection-deadline",
|
||||
"policy": "SHIFT",
|
||||
"authorityFields": ["general.meta.next_change_tick"],
|
||||
"projectionFields": ["general.meta.next_change", "general.meta.nextChangeAt"],
|
||||
"owner": "game-engine/select-pool"
|
||||
},
|
||||
{
|
||||
"key": "general-recent-war-occurrence",
|
||||
"policy": "KEEP",
|
||||
@@ -67,8 +121,15 @@
|
||||
"key": "auction-open-occurrence",
|
||||
"policy": "KEEP",
|
||||
"authorityFields": ["auction.open_tick"],
|
||||
"projectionFields": ["auction.created_at"],
|
||||
"owner": "game-api/auction"
|
||||
"projectionFields": [],
|
||||
"owner": "game-engine/auction"
|
||||
},
|
||||
{
|
||||
"key": "auction-bid-occurrence",
|
||||
"policy": "KEEP",
|
||||
"authorityFields": ["auction_bid.occurred_game_tick"],
|
||||
"projectionFields": ["auction_bid.event_at"],
|
||||
"owner": "game-engine/auction"
|
||||
},
|
||||
{
|
||||
"key": "auction-deadline",
|
||||
@@ -85,18 +146,37 @@
|
||||
"owner": "game-api/auction-worker"
|
||||
},
|
||||
{
|
||||
"key": "message-occurrence",
|
||||
"key": "message-action-occurrence",
|
||||
"policy": "KEEP",
|
||||
"authorityFields": ["message.time_tick"],
|
||||
"projectionFields": ["message.time"],
|
||||
"owner": "game-engine/message"
|
||||
"authorityFields": ["message_action.created_game_tick"],
|
||||
"projectionFields": ["message.time", "message.time_tick"],
|
||||
"rowScope": "messages with a message_action row only",
|
||||
"owner": "game-engine/message-action"
|
||||
},
|
||||
{
|
||||
"key": "message-expiry",
|
||||
"key": "message-action-expiry",
|
||||
"policy": "SHIFT",
|
||||
"authorityFields": ["message.valid_until_tick"],
|
||||
"projectionFields": ["message.valid_until"],
|
||||
"owner": "game-engine/message"
|
||||
"authorityFields": ["message_action.expires_game_tick"],
|
||||
"projectionFields": ["message.valid_until", "message.valid_until_tick"],
|
||||
"rowScope": "messages with a message_action row only",
|
||||
"owner": "game-engine/message-action"
|
||||
},
|
||||
{
|
||||
"key": "message-action-clock-coordinate",
|
||||
"policy": "REBUILD",
|
||||
"authorityFields": ["message_action.clock_revision", "message_action.deadline_generation"],
|
||||
"projectionFields": [],
|
||||
"owner": "game-engine/message-action"
|
||||
},
|
||||
{
|
||||
"key": "inheritance-effect-coordinate",
|
||||
"policy": "KEEP",
|
||||
"authorityFields": [
|
||||
"inheritance_ledger.applied_clock_revision",
|
||||
"inheritance_ledger.applied_deadline_generation"
|
||||
],
|
||||
"projectionFields": [],
|
||||
"owner": "game-engine/inheritance"
|
||||
},
|
||||
{
|
||||
"key": "vote-start-occurrence",
|
||||
@@ -127,7 +207,7 @@
|
||||
"owner": "game-engine/npc-selection"
|
||||
},
|
||||
{
|
||||
"key": "accepted-command-coordinate",
|
||||
"key": "daemon-command-coordinate",
|
||||
"policy": "KEEP",
|
||||
"authorityFields": [
|
||||
"input_event.accepted_game_tick",
|
||||
@@ -139,7 +219,7 @@
|
||||
"input_event.processing_clock_revision",
|
||||
"input_event.processing_deadline_generation"
|
||||
],
|
||||
"owner": "game-api/input-event"
|
||||
"owner": "game-engine/input-event-claim"
|
||||
},
|
||||
{
|
||||
"key": "tournament-deadlines",
|
||||
@@ -220,6 +300,17 @@
|
||||
"clock_projection_outbox.available_at",
|
||||
"clock_projection_outbox.locked_at",
|
||||
"clock_projection_outbox.applied_at",
|
||||
"message.created_at_wall",
|
||||
"message.delete_until_wall",
|
||||
"message.tombstoned_at_wall",
|
||||
"message_action.created_at_wall",
|
||||
"message_action.updated_at_wall",
|
||||
"auction.created_at",
|
||||
"auction.updated_at",
|
||||
"auction_bid.requested_at_wall",
|
||||
"auction_bid.created_at",
|
||||
"inheritance_ledger.requested_at_wall",
|
||||
"inheritance_ledger.consumed_at_wall",
|
||||
"*.created_at",
|
||||
"*.updated_at"
|
||||
]
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
## Product contract
|
||||
|
||||
Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000`
|
||||
ticks. Wall time is an observation and operational-control input, never the
|
||||
authority for gameplay ordering. A long suspension advances the observed game
|
||||
coordinate to the resume wall instant without replaying skipped turns, monthly
|
||||
events, RNG, auctions, or tournaments. Every movable future schedule is shifted
|
||||
by the same exact tick delta, including the sub-turn remainder.
|
||||
ticks. Wall time is separately authoritative for account, community, audit,
|
||||
lease, retry, notification, and operational rules. It is never projected into a
|
||||
game deadline. A long suspension advances the observed game coordinate to the
|
||||
resume wall instant without replaying skipped turns, monthly events, RNG,
|
||||
auctions, or tournaments. Every movable future GAME schedule is shifted by the
|
||||
same exact tick delta, including the sub-turn remainder. WALL occurrences and
|
||||
deadlines are outside that operation.
|
||||
|
||||
The clock state is stored in `world_state`:
|
||||
|
||||
@@ -50,6 +52,16 @@ The authoritative registry is
|
||||
architecture gate rejects a new tick/revision field that is absent from that
|
||||
inventory.
|
||||
|
||||
The participant set contains only GAME authority or its projections: world and
|
||||
turn cursors, general turns/recent-war occurrences/reselection deadlines,
|
||||
auction occurrences/deadlines, actionable-message occurrences/deadlines,
|
||||
vote deadlines, selection/NPC windows, input-event game coordinates,
|
||||
tournament Redis deadlines, and clock-operation metadata. A normal message's
|
||||
`created_at_wall`/`delete_until_wall`, inheritance receipts, notification and
|
||||
outbox retry timestamps, leases, and audit columns are explicitly excluded.
|
||||
The former broad `message-expiry` meaning is split into
|
||||
`message-action-expiry`; an envelope has no GAME lifetime.
|
||||
|
||||
## Unification wait
|
||||
|
||||
A unification month with an invader choice changes `RUNNING -> SUSPENDED` and
|
||||
@@ -109,9 +121,11 @@ turn-daemon fencing row
|
||||
-> Redis outbox projection
|
||||
```
|
||||
|
||||
The ordinary turn flush already validates phase, revision, and deadline
|
||||
generation after taking this lock prefix. Clock operation participants will be
|
||||
added without changing that prefix.
|
||||
The ordinary turn flush and daemon command claim validate phase, revision, and
|
||||
deadline generation after taking this lock prefix. WALL-only message/account
|
||||
operations do not take this lock and remain available while suspended. Hybrid
|
||||
operations commit their GAME effect only behind this fence; inheritance debit,
|
||||
receipt, effect, and command success are one transaction.
|
||||
|
||||
## Opening invariant
|
||||
|
||||
@@ -137,8 +151,17 @@ all present. Before that boundary, the loader and ordinary turn-flush fence both
|
||||
treat the row as legacy `MANUAL`; the first fenced flush installs the complete
|
||||
snapshot atomically instead of trusting the new column's `RUNNING` database
|
||||
default. Input-event acceptance does not use that compatibility fallback: an
|
||||
API or worker may enqueue gameplay only after the authoritative clock is fully
|
||||
initialized.
|
||||
API or worker records only a DB-wall receipt, then the daemon establishes the
|
||||
GAME coordinate while claiming under the authoritative fence. Rolling-upgrade
|
||||
payload coordinates may be parsed and ignored, but never become rule authority.
|
||||
|
||||
Migration `20260903140000_split_message_wall_and_game_time` separates message
|
||||
envelopes from actions and adds explicit auction-bid occurrence/request facts,
|
||||
inheritance receipts, and selection cooldown tick authority. Legacy projection
|
||||
columns remain temporarily for old readers. A missing GAME tick fails closed;
|
||||
it never changes the rule to WALL_TIME. A WALL rule likewise never derives an
|
||||
authority tick. See [`time-domains.md`](./time-domains.md) for the complete
|
||||
inventory and migration policy.
|
||||
|
||||
No active participant remains `FORBID`. Tournament writes carry
|
||||
tick/revision/generation coordinates and are revision-fenced in Redis.
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
# 게임 시계
|
||||
|
||||
게임 진행 시각은 `world_state.clock_tick`이 기준입니다. 벽시계는 daemon lease,
|
||||
요청 timeout, 처리 budget과 같은 운영 제어에만 사용합니다. 장수 턴, 메시지
|
||||
유효기간, 투표, 경매와 대회 마감은 game tick 또는 그 tick에서 투영한 시각을
|
||||
사용합니다.
|
||||
시간 규칙은 `GAME_TIME`, `WALL_TIME`, `MONOTONIC_ELAPSED_TIME`로
|
||||
나뉩니다. 게임 진행의 권위는 `world_state.clock_tick`, 영속 wall
|
||||
판정의 권위는 PostgreSQL UTC 시계, 프로세스 내부 경과시간의 권위는
|
||||
monotonic clock입니다. 장수 턴·외교 효력·게임 경매·투표·대회는 GAME,
|
||||
일반 메시지·계정·감사·lease·retry는 WALL입니다. 전체 필드별 계약은
|
||||
[`time-domains.md`](./time-domains.md)를 따릅니다.
|
||||
|
||||
한 턴은 항상 `36,000,000` tick입니다. `tick_seconds`가 바뀌면 현재 표시
|
||||
한 턴은 항상 `36,000,000` tick입니다. `tick_seconds`가 바뀌면 현재 GAME 표시
|
||||
시각이 유지되도록 `clock_base_time`을 다시 계산하므로, 기존 장수 턴 순서와
|
||||
남은 턴 수가 보존됩니다. DateTime 필드는 이전 데이터와 화면을 위한 투영값이며
|
||||
tick 필드가 존재하면 tick이 우선합니다.
|
||||
남은 턴 수가 보존됩니다. GAME 규칙의 DateTime은 화면/레거시 투영일 뿐이며
|
||||
tick이 반드시 authority입니다. WALL 규칙은 tick이 없어도 정상이며
|
||||
DateTime을 tick으로 변환해 판정하지 않습니다.
|
||||
|
||||
운영 중 턴 간격 변경은 Gateway의 내구성 런타임 작업으로만 수행합니다. 같은
|
||||
transaction에서 `world_state`, 장수·경매·메시지·설문 투영값과 checkpoint를
|
||||
transaction에서 `world_state`, 장수·경매·actionable message·설문 투영값과 checkpoint를
|
||||
갱신하며 기존 역사/행동 로그의 `created_at`은 다시 쓰지 않습니다. 토너먼트의
|
||||
Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
|
||||
|
||||
@@ -29,6 +32,18 @@ Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
|
||||
프로필 설치 시 선택한 모드는 DB에 저장됩니다. daemon의 환경변수는 로드한
|
||||
모드를 명시적으로 덮어쓸 때만 사용해 주세요.
|
||||
|
||||
`SUSPENDED`와 `RECONCILING`에서는 `GameClock.nowTick()`이 wall anchor 이후의
|
||||
현실 경과시간을 더하지 않고 저장된 `clock_tick`을 그대로 반환합니다. 따라서
|
||||
24시간 동안 정지해도 actionable message, 토너먼트와 국가 베팅의 GAME deadline은
|
||||
줄지 않습니다. 일반 메시지 envelope와 5분 삭제 기한은 별도의 DB WALL_TIME이라
|
||||
같은 기간 계속 흐릅니다. 정지 전에 도착한 등용장도 envelope로 계속 수신·열람할
|
||||
수 있지만, 등용 수락 효과는 daemon GAME fence가 다시 열릴 때까지 적용되지 않습니다.
|
||||
|
||||
이미 열린 토너먼트·국가 베팅에는 `SUSPENDED` 중에도 새 베팅을 제출할 수 있습니다.
|
||||
이때 베팅 가능 여부는 frozen GAME coordinate로 판정하고, 재화 mutation은 현재
|
||||
phase/revision/generation을 다시 잠가 검증합니다. 단계 전환·마감·정산은 실행하지
|
||||
않으며, 원자적 reconciliation이 진행되는 `RECONCILING`에서는 새 베팅도 받지 않습니다.
|
||||
|
||||
## 중단 후 재개
|
||||
|
||||
realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용합니다.
|
||||
@@ -45,21 +60,24 @@ realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용
|
||||
장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행
|
||||
시각으로 다시 고정합니다.
|
||||
|
||||
DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 migration
|
||||
DB migration은 GAME 규칙의 기존 DateTime 투영에서 tick을 채웁니다. 새 설치와 migration
|
||||
재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은
|
||||
무기한 호환값은 안전한 정수 범위를 넘을 수 있으므로 tick을 `NULL`로 두고
|
||||
DateTime fallback을 사용합니다.
|
||||
무기한 호환값은 일반 메시지의 투영일 뿐입니다. actionable deadline은
|
||||
`expires_game_tick`, 일반 삭제 deadline은 `delete_until_wall`만이 authority이며
|
||||
NULL에 따라 다른 시계로 fallback하지 않습니다.
|
||||
|
||||
## 비동기 작업의 시계 경계
|
||||
|
||||
게임 규칙의 수락·입찰·예약 시각은 logical game time을 사용하지만 daemon
|
||||
queue의 `InputEvent.createdAt`, worker history retention과 timeout은 운영
|
||||
벽시계를 사용합니다. NPC 빙의 enqueue는 현재 logical game time을 event
|
||||
payload의 `acceptedGameAt`에 고정합니다. queue에 들어갈 때 유효했던 token은
|
||||
처리 전 game tick이 진행해도 이 저장된 논리 수락 시각으로 다시 검증합니다.
|
||||
외부 요청은 `InputEvent.createdAt` DB WALL_TIME으로 접수합니다. API payload가
|
||||
game tick을 미리 고정하지 않으며, daemon이 clock lock/fence 아래서 claim할 때
|
||||
`accepted_game_tick`/세대와 `processing_game_tick`/세대를 확정합니다. NPC,
|
||||
선택, 투표, 경매, 유산 효과는 처리 tick으로 검증하며 stale revision은
|
||||
적용하지 않습니다. worker history retention·lease·retry는 DB WALL_TIME,
|
||||
프로세스 대기 budget은 monotonic time입니다.
|
||||
|
||||
경매 입찰은 같은 logical tick에서 여러 번 일어날 수 있습니다. bid 표시
|
||||
시각은 같은 game time을 보존하고, optimistic 경합 판정은 임의 UUID의
|
||||
경매 입찰은 `requested_at_wall`로 현실 요청을, `occurred_game_tick`으로 GAME
|
||||
사건을 별도 기록합니다. bid 표시 투영은 같은 game time을 보존하고,
|
||||
optimistic 경합 판정은 임의 UUID의
|
||||
사전순이 아니라 읽은 `latest_event_id`를 버전 토큰으로 사용합니다. worker
|
||||
재시작 시 `OPEN`은 `close_tick` deadline에, 이미 마감 판정이 끝난
|
||||
`FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Time-domain inventory
|
||||
|
||||
This document is the authoritative classification of persistent timestamps,
|
||||
deadlines, cooldowns, and process-local elapsed-time rules. Classification is
|
||||
per rule, not per table. A feature may record both a wall occurrence and a game
|
||||
effect; those are two facts, never one fallback clock.
|
||||
|
||||
## Domain contract
|
||||
|
||||
| Domain | Authority | Advances while suspended/reconciling | Reconciliation |
|
||||
| ------------------------ | -------------------------------------------------------------- | ------------------------------------ | ----------------------------- |
|
||||
| `GAME_TIME` | `world_state.clock_tick` under phase/revision/generation fence | no | `SHIFT`, `KEEP`, or `REBUILD` |
|
||||
| `WALL_TIME` | PostgreSQL UTC `CURRENT_TIMESTAMP` for persistent decisions | yes | never |
|
||||
| `MONOTONIC_ELAPSED_TIME` | `performance.now()` / monotonic process clock | process-local only | never persisted |
|
||||
|
||||
`GameTick`, `ClockRevision`, `DeadlineGeneration`, `WallInstant`, and
|
||||
`MonotonicDuration` name these meanings in new/refactored APIs. Existing
|
||||
`createdAt`/`updatedAt` fields remain wall audit timestamps unless this inventory
|
||||
explicitly calls them game projections.
|
||||
|
||||
## Game database inventory
|
||||
|
||||
`Pause` means whether the rule continues to age during `SUSPENDED` or
|
||||
`RECONCILING`. `Projection` means a non-authoritative compatibility/display
|
||||
representation.
|
||||
|
||||
| Table / rule / field(s) | Current meaning | Domain and authority | Pause | Reconcile / projection | Decision and reason |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `world_state.clock_tick` | observed world coordinate | GAME, self-authoritative | stop | REBUILD | root of all game-time decisions |
|
||||
| `world_state.last_turn_tick` | executed turn cursor | GAME | stop | SHIFT | execution order must preserve skipped-turn policy |
|
||||
| `world_state.clock_revision`, `deadline_generation` | clock/future-deadline generations | GAME metadata | stop | REBUILD | stale commands/workers must fail their fence |
|
||||
| `world_state.clock_base_time`, `clock_wall_anchor` | tick-to-date mapping and wall observation anchor | GAME projection metadata | n/a | REBUILD | not business wall deadlines |
|
||||
| `world_state.updated_at` | row audit | WALL, DB UTC | advance | excluded | operational history is not shifted |
|
||||
| `clock_suspension.source_revision`, `target_revision`, `cut_tick`, `catch_up_ticks`, `gap_ticks`, `shift_ticks`, `aligned_tick` | reconciliation plan/audit | GAME metadata | stop | KEEP | immutable clock operation facts |
|
||||
| `clock_suspension.cut_wall_at`, `resume_wall_at`, `created_at`, `updated_at` | operator/runtime occurrence audit | WALL, DB UTC | advance | excluded | records when the real operation occurred |
|
||||
| `clock_projection_outbox.target_revision` | target GAME generation | GAME metadata | stop | KEEP | projection fence |
|
||||
| `clock_projection_outbox.available_at`, `locked_at`, `applied_at`, `created_at`, `updated_at` | retry/lease/audit | WALL, DB UTC | advance | excluded | worker control cannot pause with game time |
|
||||
| `clock_reconciliation_participant` checksum/count/policy | immutable operation evidence | GAME operation metadata | n/a | KEEP | evidence, not a deadline |
|
||||
| `input_event.accepted_game_tick`, `accepted_clock_revision`, `accepted_deadline_generation` | daemon-claim boundary | GAME metadata | stop | KEEP across matching revision; rebase pending legacy rows only | API does not pre-stamp these; daemon owns acceptance |
|
||||
| `input_event.processing_game_tick`, `processing_clock_revision`, `processing_deadline_generation` | actual mutation boundary | GAME metadata | stop | KEEP | effect validation/RNG uses this coordinate |
|
||||
| `input_event.created_at`, `processing_at`, `completed_at`, `lease_until` | request receipt, processing audit, lease | WALL, DB UTC | advance | excluded | external occurrence and worker lease |
|
||||
| `read_model_outbox.*_at`, `web_push_outbox.*_at` | availability, claim, delivery, audit | WALL, DB UTC | advance | excluded | retry and notification delivery are operational |
|
||||
| `turn_daemon_lease.lease_until`, `heartbeat_at` | daemon liveness | WALL, DB UTC | advance | excluded | a paused game must still lose a dead daemon lease |
|
||||
| `general.turn_tick` | next general turn | GAME | stop | SHIFT; `turn_time` projection | determines engine order |
|
||||
| `general.recent_war_tick` | past battle occurrence | GAME | stop | KEEP; `recent_war_time` projection | historical event does not move |
|
||||
| `general.meta.next_change_tick` | N-turn reselection cooldown | GAME | stop | SHIFT; `next_change`/`nextChangeAt` projections | expressed in turns; missing tick fails closed |
|
||||
| `general.created_at`, `updated_at` | entity audit | WALL, DB UTC | advance | excluded | no gameplay deadline meaning |
|
||||
| `select_pool.reserved_until_tick` | selection reservation deadline | GAME | stop | SHIFT; `reserved_until` projection | reservation is measured in game turns |
|
||||
| `select_npc_token.valid_until_tick`, `pick_more_from_tick` | NPC selection windows | GAME | stop | SHIFT; DateTime projections | token is a game selection schedule; missing ticks fail closed |
|
||||
| `general_access_log.last_refresh`, `last_action_at`; `general_access_batch.created_at`; `traffic_period.started_at`, `last_refresh`; `traffic_period_general.last_refresh` | traffic/access accounting | WALL, DB UTC | advance | excluded | community/operations usage, not world progression |
|
||||
| `message.created_at_wall`, `delete_until_wall`, `tombstoned_at_wall` | envelope send/delete lifecycle | WALL, DB UTC | advance | excluded | normal messages work while the game is paused; deletion is real five minutes |
|
||||
| `message.occurred_game_tick` | optional game context | GAME occurrence | stop | KEEP | context only, never deletion authority |
|
||||
| `message.time`, `time_tick`, `valid_until`, `valid_until_tick` | rolling compatibility projections | projection only | n/a | recompute only for `message_action`; general-envelope values never decide lifecycle | old columns are not fallback authority |
|
||||
| `message_action.created_game_tick`, `resolved_game_tick` | action occurrence/resolution | GAME occurrence | stop | KEEP | actionable message lifecycle is separate from envelope |
|
||||
| `message_action.expires_game_tick` | proposal response deadline | GAME | stop | SHIFT | remaining game duration survives pause |
|
||||
| `message_action.clock_revision`, `deadline_generation` | response fence | GAME metadata | stop | REBUILD | stale responses are rejected |
|
||||
| `message_action.created_at_wall`, `updated_at_wall`; `message_read_state.updated_at` | audit/read occurrence | WALL, DB UTC | advance | excluded | community UX state |
|
||||
| `diplomacy_letter.date` | document authored/sent time | WALL, DB UTC | advance | excluded | game effect dates live in diplomacy/action state, not the document timestamp |
|
||||
| diplomacy war/nonaggression start/end month data | diplomatic effect schedule | GAME calendar | stop | handled by engine schedule | affects world turns and war validity |
|
||||
| `inheritance_point.updated_at`, `inheritance_log.created_at`, `inheritance_result.created_at`, baseline/user-state audit fields | account ledger/result audit | WALL, DB UTC | advance | excluded | account/external-currency history |
|
||||
| `inheritance_ledger.requested_at_wall`, `consumed_at_wall`, `created_at_wall` | direct purchase receipt | WALL, DB UTC | advance | excluded | real request/debit receipt |
|
||||
| `inheritance_ledger.applied_clock_revision`, `applied_deadline_generation` | game-effect fence metadata | GAME metadata | stop | KEEP | no `applied_game_tick`: current direct effects are timeless immediate state changes |
|
||||
| inheritance command `input_event` | durable effect state/idempotency | WALL receipt + GAME processing fence | mixed, separated | only GAME coordinate participates | one transaction commits debit, receipt, effect, and command success; failure leaves durable input event and no debit |
|
||||
| `auction.open_tick`, `auction_bid.occurred_game_tick` | open/bid game occurrence | GAME | stop | KEEP; bid `event_at` is projection | event order/RNG/replay context |
|
||||
| `auction.close_tick` | in-world close deadline | GAME | stop | SHIFT; `close_at` projection | authoritative worker/finalizer deadline; missing tick fails closed |
|
||||
| `auction_bid.requested_at_wall`, `created_at`; `auction.finalizing_at`, `finished_at`, `created_at`, `updated_at` | request/processing/audit | WALL, DB UTC | advance | excluded | real action and recovery history |
|
||||
| `auction.latest_event_at` | optimistic compatibility projection of latest game event | GAME projection | stop | follows authoritative event tick | never used as wall deadline |
|
||||
| `vote_poll.start_tick`, `end_tick` | poll occurrence/deadline | GAME | stop | KEEP/SHIFT; `start_at`/`end_at` projections | poll is an in-world survey; missing deadline tick fails closed |
|
||||
| `vote_poll.closed_at`, `created_at`, `updated_at`; `vote.created_at`; `vote_comment.created_at` | closure/user/audit occurrence | WALL, DB UTC | advance | excluded | closure receipt and community content history |
|
||||
| tournament `nextTick`, `bettingCloseTick` in Redis | stage/betting deadlines | GAME | stop | REBUILD; `nextAt`/`bettingCloseAt` projections | stages advance with the world; legacy date-only state fails closed |
|
||||
| nation betting open/close year-month and tournament phase | in-world availability | GAME calendar/tick | stop | engine/Redis participant | tied to tournament turns |
|
||||
| tournament/nation bet submission | user WALL request + effect at the current frozen GAME coordinate | WALL + GAME, separated | submission is allowed during `SUSPENDED`; GAME deadline does not age | receipt excluded; GAME availability/fence retained | pausing stage progress must not close an already-open betting window |
|
||||
| `nation_betting.*_at`, `nation_bet.*_at` | user/audit occurrence | WALL, DB UTC | advance | excluded | receipts, not close authority |
|
||||
| `game_history.date`, old-general `turntime`, archived projected dates | archived game-calendar projection | GAME historical display | stop | KEEP, never shifted | immutable archive/replay record |
|
||||
| archive/entity `created_at`, cancellation `opened_at`/`cancelled_at`, unification `completed_at` | operation/archive audit | WALL, DB UTC | advance | excluded | real creation/completion facts |
|
||||
| `general_turn_revision.lease_expires_at`, `nation_turn_revision.lease_expires_at` and audit timestamps | edit lease/revision audit | WALL, DB UTC | advance | excluded | editor concurrency timeout |
|
||||
| board post/comment, log/error/event, legacy migration timestamps | content/audit/migration history | WALL, DB UTC | advance | excluded | community and operational evidence |
|
||||
|
||||
The year-9999 message sentinel remains a legacy projection only.
|
||||
`MAX_SAFE_GAME_TICK` is the separate GAME-domain infinite sentinel. Neither is
|
||||
converted into or used as the other domain's ordinary deadline.
|
||||
|
||||
## Gateway database inventory
|
||||
|
||||
Gateway has no gameplay clock authority. Every Gateway `DateTime` is WALL_TIME:
|
||||
|
||||
- `app_user`: identity/session/icon/terms/privacy/Kakao/grace/deletion/login and
|
||||
`created_at`/`updated_at` fields.
|
||||
- access grants, retired identities, admin audits, user icons, legacy member
|
||||
logs, and migration timestamps.
|
||||
- profile lifecycle `preopen_at`, `open_at`, `scheduled_start_at`, build request,
|
||||
start/completion/last-used, and row audit timestamps. These are real control
|
||||
plane schedules; they do not replace a profile's `world_state.clock_tick`.
|
||||
- subscriptions/preferences/receipts/notifications and web-push delivery
|
||||
`available_at`, `locked_at`, `delivered_at`, expiration and audit fields.
|
||||
- runtime actions, operations, releases, and bulk releases: schedule, start,
|
||||
completion, retry, lease, heartbeat, successful and audit timestamps.
|
||||
|
||||
Competitive Gateway operation/release claim and lease renewal decisions read
|
||||
PostgreSQL `CURRENT_TIMESTAMP` inside the persistence transaction; the caller's
|
||||
poll timestamp is not authoritative. A Gateway PREOPEN wall schedule is an operational request; once
|
||||
the game exists, gameplay schedules use the game database tick.
|
||||
|
||||
## Process-local monotonic inventory
|
||||
|
||||
The following are `MONOTONIC_ELAPSED_TIME` and are never persisted: daemon/RPC
|
||||
wait budgets, turn processing budgets, worker poll/resync intervals, lock
|
||||
acquisition waits, readiness loops, short-lived cache TTLs, latency metrics, and
|
||||
test wait loops. Production implementations use `performance.now()` where an
|
||||
elapsed duration is measured. `Date.now()`/`new Date()` remains valid only when
|
||||
creating or formatting a WALL occurrence, calculating a non-competitive auth
|
||||
TTL for an external protocol, or providing an explicit test clock.
|
||||
|
||||
## API phase policy
|
||||
|
||||
| Operation | During `SUSPENDED` / `RECONCILING` | Fence |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| normal public/private/nation message send/read/delete, including receiving/reading an existing recruitment letter envelope | allowed | WALL DB transaction only; actionable deadline remains frozen GAME state |
|
||||
| notification/account/inheritance history/audit reads | allowed | WALL |
|
||||
| actionable message response | rejected/queued except the explicitly authorized unification response | daemon clock phase/revision/generation |
|
||||
| tournament/nation bet submission while its GAME window is open | allowed in `SUSPENDED`; rejected in `RECONCILING` | frozen GAME deadline + phase/revision/generation fence |
|
||||
| tournament stage transition/close/settlement and nation-bet close/settlement | not applied | daemon GAME fence |
|
||||
| turn, reservation, war/diplomacy effect, auction, vote, other tournament mutation | not applied | daemon GAME fence |
|
||||
| direct inheritance state mutation | rejected if the daemon GAME fence cannot commit | atomic input-event + revision/generation fence |
|
||||
|
||||
There is currently no account-only inheritance purchase endpoint. Direct
|
||||
inheritance commands use policy 1 (no debit if game mutation cannot commit), not
|
||||
an ambiguous partially-applied state. Their `InputEvent.requestId` is the durable
|
||||
idempotency/effect state; `InheritanceLedger.requestId` proves the one successful
|
||||
receipt.
|
||||
|
||||
## `loadCurrentGameTime` call-site audit
|
||||
|
||||
All production call sites were reviewed. The remaining uses are GAME-only:
|
||||
|
||||
- `messages/store`: create/read/invalidate the separate `message_action`; normal
|
||||
envelope creation, display, read state, and deletion do not load game time.
|
||||
- `messages/diplomaticResponse`: game-effect/log calendar context after an
|
||||
actionable response fence; the letter's authored date is DB WALL_TIME.
|
||||
- auction `open`, `scheduler`, `worker`, and router: open/close tick projection,
|
||||
due evaluation, and fence context; bid receipt time is separate WALL_TIME.
|
||||
- vote router, tournament router/worker: GAME poll/stage deadlines and Redis
|
||||
projection fences.
|
||||
- troop/general/join selection routers: current world turn/schedule context;
|
||||
the NPC reservation mutation holds the clock advisory lock and `world_state`
|
||||
row fence before reading it.
|
||||
- lobby: display-only projected server game time and phase.
|
||||
|
||||
No inheritance receipt, ordinary message timestamp/delete rule, audit log,
|
||||
lease, outbox retry, API timeout, or worker budget calls this helper.
|
||||
|
||||
Ordinary message send/read-state/delete mutations retain their durable API
|
||||
`InputEvent` transaction and read-model journal, but use the WALL-only input
|
||||
boundary. That boundary deliberately does not acquire the game clock advisory
|
||||
lock, so a reconciliation transaction cannot unnecessarily serialize community
|
||||
messaging. Actionable responses continue to use the GAME-fenced boundary.
|
||||
|
||||
## Migration boundary
|
||||
|
||||
Migration `20260903140000_split_message_wall_and_game_time` adds and backfills
|
||||
the explicit message, action, auction-bid, inheritance receipt, and selection
|
||||
cooldown authorities. It never extends an already-expired message delete window.
|
||||
Old projection columns remain during rolling deployment, but new code never
|
||||
chooses a clock by NULL fallback: GAME rules require their tick; WALL rules use
|
||||
their wall column. The disposable migration verifier covers populated upgrade,
|
||||
indexes/constraints, replay safety, and a second no-op deploy.
|
||||
@@ -9,13 +9,18 @@ declare const gameTickBrand: unique symbol;
|
||||
declare const observedGameInstantBrand: unique symbol;
|
||||
declare const scheduleInstantBrand: unique symbol;
|
||||
declare const clockRevisionBrand: unique symbol;
|
||||
declare const deadlineGenerationBrand: unique symbol;
|
||||
declare const wallInstantBrand: unique symbol;
|
||||
declare const monotonicDurationBrand: unique symbol;
|
||||
|
||||
export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' };
|
||||
export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' };
|
||||
export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' };
|
||||
export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' };
|
||||
export type DeadlineGeneration = number & { readonly [deadlineGenerationBrand]: 'DeadlineGeneration' };
|
||||
export type WallInstant = Date & { readonly [wallInstantBrand]: 'WallInstant' };
|
||||
/** Process-local elapsed milliseconds; never persist this value as a business timestamp. */
|
||||
export type MonotonicDuration = number & { readonly [monotonicDurationBrand]: 'MonotonicDuration' };
|
||||
|
||||
export interface ClockAlignmentPlan {
|
||||
policy: ClockAlignmentPolicy;
|
||||
@@ -59,6 +64,13 @@ export const asClockRevision = (revision: number): ClockRevision => {
|
||||
return revision as ClockRevision;
|
||||
};
|
||||
|
||||
export const asDeadlineGeneration = (generation: number): DeadlineGeneration => {
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) {
|
||||
throw new Error(`Deadline generation must be a positive safe integer: ${generation}`);
|
||||
}
|
||||
return generation as DeadlineGeneration;
|
||||
};
|
||||
|
||||
export const asWallInstant = (instant: Date): WallInstant => {
|
||||
if (Number.isNaN(instant.getTime())) {
|
||||
throw new Error('Wall instant must be a valid date.');
|
||||
@@ -66,6 +78,13 @@ export const asWallInstant = (instant: Date): WallInstant => {
|
||||
return new Date(instant.getTime()) as WallInstant;
|
||||
};
|
||||
|
||||
export const asMonotonicDuration = (milliseconds: number): MonotonicDuration => {
|
||||
if (!Number.isFinite(milliseconds) || milliseconds < 0) {
|
||||
throw new Error(`Monotonic duration must be a non-negative finite number: ${milliseconds}`);
|
||||
}
|
||||
return milliseconds as MonotonicDuration;
|
||||
};
|
||||
|
||||
export const inferClockPhase = (mode: GameClockMode): GameClockPhase => (mode === 'manual' ? 'MANUAL' : 'RUNNING');
|
||||
|
||||
const GAME_CLOCK_PHASES: readonly GameClockPhase[] = [
|
||||
@@ -136,8 +155,7 @@ const buildAlignmentPlan = (input: {
|
||||
const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000;
|
||||
const gapTicks = asGameTick(
|
||||
requireSafeTick(
|
||||
wholeSeconds * input.ticksPerSecond +
|
||||
Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000)
|
||||
wholeSeconds * input.ticksPerSecond + Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000)
|
||||
)
|
||||
);
|
||||
const catchUpTicks = asGameTick(input.catchUpTicks ?? 0);
|
||||
@@ -250,7 +268,13 @@ export class GameClock {
|
||||
}
|
||||
|
||||
nowTick(wallNow: Date): number {
|
||||
if (this.mode === 'manual' || this.phase === 'MANUAL' || this.phase === 'COMPLETED') {
|
||||
if (
|
||||
this.mode === 'manual' ||
|
||||
this.phase === 'MANUAL' ||
|
||||
this.phase === 'SUSPENDED' ||
|
||||
this.phase === 'RECONCILING' ||
|
||||
this.phase === 'COMPLETED'
|
||||
) {
|
||||
return this.tick;
|
||||
}
|
||||
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface TournamentClockFence {
|
||||
phaseKey: string;
|
||||
revision: number;
|
||||
deadlineGeneration: number;
|
||||
phase: 'RUNNING';
|
||||
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
||||
}
|
||||
|
||||
const WRITE_TOURNAMENT_PROJECTION_SCRIPT = `
|
||||
|
||||
@@ -190,7 +190,7 @@ export type TurnDaemonCommand =
|
||||
requestId?: string;
|
||||
auctionId: number;
|
||||
expectedCloseAt?: string;
|
||||
expectedCloseTick?: number;
|
||||
expectedCloseTick: number;
|
||||
}
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
@@ -260,7 +260,6 @@ export type TurnDaemonCommand =
|
||||
voteId: number;
|
||||
generalId: number;
|
||||
selection: number[];
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'setNationSetting';
|
||||
@@ -386,15 +385,12 @@ export type TurnDaemonCommand =
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
generalId: number;
|
||||
tokenNonce: number;
|
||||
acceptedGameAt?: string;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolReserve';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
seedOwnerIdentity: string | number;
|
||||
acceptedGameAt: string;
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
@@ -407,8 +403,6 @@ export type TurnDaemonCommand =
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
ownerIconRevision?: string;
|
||||
acceptedGameAt?: string;
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolReselect';
|
||||
@@ -416,8 +410,6 @@ export type TurnDaemonCommand =
|
||||
userId: string;
|
||||
ownerDisplayName: string;
|
||||
uniqueName: string;
|
||||
acceptedGameAt?: string;
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
@@ -426,7 +418,6 @@ export type TurnDaemonCommand =
|
||||
auctionId: number;
|
||||
generalId: number;
|
||||
amount: number;
|
||||
acceptedGameTick?: number;
|
||||
tryExtendCloseDate?: boolean;
|
||||
};
|
||||
|
||||
@@ -505,6 +496,7 @@ export type TurnDaemonCommandResult =
|
||||
ok: true;
|
||||
auctionId: number;
|
||||
closeAt: string;
|
||||
closeTick: number;
|
||||
}
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
@@ -835,6 +827,7 @@ export type TurnDaemonCommandResult =
|
||||
ok: true;
|
||||
auctionId: number;
|
||||
closeAt: string;
|
||||
closeTick: number;
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
|
||||
@@ -13,6 +13,24 @@ import {
|
||||
} from '../src/time/GameClock.js';
|
||||
|
||||
describe('GameClock', () => {
|
||||
it.each(['SUSPENDED', 'RECONCILING'] as const)(
|
||||
'keeps the authoritative tick frozen across 24 wall hours while %s',
|
||||
(phase) => {
|
||||
const clock = new GameClock({
|
||||
baseTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
tick: 12_345,
|
||||
mode: 'realtime',
|
||||
wallAnchor: new Date('2026-09-03T15:00:00.000Z'),
|
||||
turnSeconds: 600,
|
||||
phase,
|
||||
revision: 7,
|
||||
});
|
||||
|
||||
expect(clock.nowTick(new Date('2026-09-04T15:00:00.000Z'))).toBe(12_345);
|
||||
expect(clock.now(new Date('2026-09-04T15:00:00.000Z'))).toEqual(clock.tickToDate(12_345));
|
||||
}
|
||||
);
|
||||
|
||||
const baseTime = new Date('2042-01-01T00:00:00.000Z');
|
||||
|
||||
it('projects the fixed Ref turn tick and ignores wall time in manual mode', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
|
||||
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
|
||||
"verify:migration:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh",
|
||||
"verify:migration:time-domains": "sh scripts/verify-time-domain-migration.sh",
|
||||
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
|
||||
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
|
||||
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
||||
|
||||
@@ -433,20 +433,50 @@ model MessageReadState {
|
||||
}
|
||||
|
||||
model Message {
|
||||
id Int @id @default(autoincrement())
|
||||
mailbox Int
|
||||
type String
|
||||
src Int
|
||||
dest Int
|
||||
time DateTime
|
||||
timeTick BigInt? @map("time_tick")
|
||||
validUntil DateTime @map("valid_until")
|
||||
validUntilTick BigInt? @map("valid_until_tick")
|
||||
message Json
|
||||
id Int @id @default(autoincrement())
|
||||
mailbox Int
|
||||
type String
|
||||
src Int
|
||||
dest Int
|
||||
/// Legacy game-date projection. Never use this as the wall occurrence authority.
|
||||
time DateTime
|
||||
/// Legacy game-date projection coordinate. New rules use occurredGameTick or MessageAction.
|
||||
timeTick BigInt? @map("time_tick")
|
||||
/// Legacy envelope/action visibility projection retained for rolling compatibility.
|
||||
validUntil DateTime @map("valid_until")
|
||||
/// Legacy action deadline projection retained for rolling compatibility.
|
||||
validUntilTick BigInt? @map("valid_until_tick")
|
||||
createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3)
|
||||
deleteUntilWall DateTime @default(dbgenerated("((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '5 minutes')")) @map("delete_until_wall") @db.Timestamp(3)
|
||||
tombstonedAtWall DateTime? @map("tombstoned_at_wall") @db.Timestamp(3)
|
||||
occurredGameTick BigInt? @map("occurred_game_tick")
|
||||
message Json
|
||||
|
||||
action MessageAction?
|
||||
|
||||
@@index([mailbox, type, id])
|
||||
@@index([deleteUntilWall])
|
||||
@@map("message")
|
||||
}
|
||||
|
||||
model MessageAction {
|
||||
messageId Int @id @map("message_id")
|
||||
actionType String @map("action_type") @db.VarChar(64)
|
||||
status String @default("PENDING") @db.VarChar(16)
|
||||
createdGameTick BigInt @map("created_game_tick")
|
||||
expiresGameTick BigInt? @map("expires_game_tick")
|
||||
resolvedGameTick BigInt? @map("resolved_game_tick")
|
||||
clockRevision BigInt @map("clock_revision")
|
||||
deadlineGeneration BigInt @map("deadline_generation")
|
||||
createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3)
|
||||
updatedAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at_wall") @db.Timestamp(3)
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status, expiresGameTick])
|
||||
@@map("message_action")
|
||||
}
|
||||
|
||||
model RankData {
|
||||
id Int @id @default(autoincrement())
|
||||
nationId Int @default(0) @map("nation_id")
|
||||
@@ -857,6 +887,26 @@ model InheritanceLog {
|
||||
@@map("inheritance_log")
|
||||
}
|
||||
|
||||
/// WALL_TIME purchase/consume receipt for an inheritance command. The linked
|
||||
/// input_event owns the authoritative GAME clock coordinate and retry state.
|
||||
model InheritanceLedger {
|
||||
id BigInt @id @default(autoincrement())
|
||||
requestId String @unique @map("request_id")
|
||||
userId String @map("user_id")
|
||||
action String
|
||||
cost Float
|
||||
status String @default("APPLIED")
|
||||
requestedAtWall DateTime @map("requested_at_wall") @db.Timestamp(3)
|
||||
consumedAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("consumed_at_wall") @db.Timestamp(3)
|
||||
appliedClockRevision BigInt @map("applied_clock_revision")
|
||||
appliedDeadlineGeneration BigInt @map("applied_deadline_generation")
|
||||
createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3)
|
||||
|
||||
@@index([userId, id])
|
||||
@@index([status, id])
|
||||
@@map("inheritance_ledger")
|
||||
}
|
||||
|
||||
model InheritanceResult {
|
||||
id Int @id @default(autoincrement())
|
||||
legacyId Int? @unique @map("legacy_id")
|
||||
@@ -897,19 +947,23 @@ model Auction {
|
||||
}
|
||||
|
||||
model AuctionBid {
|
||||
id Int @id @default(autoincrement())
|
||||
auctionId Int @map("auction_id")
|
||||
generalId Int @map("general_id")
|
||||
amount Int
|
||||
eventId String @map("event_id")
|
||||
eventAt DateTime @map("event_at")
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id Int @id @default(autoincrement())
|
||||
auctionId Int @map("auction_id")
|
||||
generalId Int @map("general_id")
|
||||
amount Int
|
||||
eventId String @map("event_id")
|
||||
/// Legacy/UI projection of occurredGameTick. Never use as expiry authority.
|
||||
eventAt DateTime @map("event_at")
|
||||
occurredGameTick BigInt @map("occurred_game_tick")
|
||||
requestedAtWall DateTime @map("requested_at_wall") @db.Timestamp(3)
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([auctionId, amount])
|
||||
@@index([auctionId, eventAt])
|
||||
@@index([auctionId, occurredGameTick])
|
||||
@@map("auction_bid")
|
||||
}
|
||||
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
-- Message envelopes are WALL_TIME. The existing time/valid_until columns are
|
||||
-- retained as rolling-deploy projections while actionable gameplay state moves
|
||||
-- to an explicit GAME_TIME record.
|
||||
ALTER TABLE message
|
||||
ADD COLUMN created_at_wall TIMESTAMP(3),
|
||||
ADD COLUMN delete_until_wall TIMESTAMP(3),
|
||||
ADD COLUMN tombstoned_at_wall TIMESTAMP(3),
|
||||
ADD COLUMN occurred_game_tick BIGINT;
|
||||
|
||||
-- Historical rows predate a trustworthy wall-occurrence field. `time` is the
|
||||
-- only available evidence, so preserve it as the best-effort occurrence while
|
||||
-- ensuring the migration can never reopen an old five-minute delete window.
|
||||
UPDATE message
|
||||
SET created_at_wall = time,
|
||||
delete_until_wall = LEAST(time + INTERVAL '5 minutes', CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
tombstoned_at_wall = CASE
|
||||
WHEN lower(COALESCE(message->'option'->>'invalid', 'false')) IN ('true', '1')
|
||||
THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
ELSE NULL
|
||||
END,
|
||||
occurred_game_tick = time_tick;
|
||||
|
||||
ALTER TABLE message
|
||||
ALTER COLUMN created_at_wall SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
ALTER COLUMN created_at_wall SET NOT NULL,
|
||||
ALTER COLUMN delete_until_wall SET DEFAULT ((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '5 minutes'),
|
||||
ALTER COLUMN delete_until_wall SET NOT NULL;
|
||||
|
||||
CREATE INDEX message_mailbox_type_id_idx ON message(mailbox, type, id);
|
||||
CREATE INDEX message_delete_until_wall_idx ON message(delete_until_wall);
|
||||
|
||||
CREATE TABLE message_action (
|
||||
message_id INTEGER PRIMARY KEY REFERENCES message(id) ON DELETE CASCADE,
|
||||
action_type VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
|
||||
created_game_tick BIGINT NOT NULL,
|
||||
expires_game_tick BIGINT,
|
||||
resolved_game_tick BIGINT,
|
||||
clock_revision BIGINT NOT NULL,
|
||||
deadline_generation BIGINT NOT NULL,
|
||||
created_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
updated_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT message_action_status_check CHECK (status IN ('PENDING', 'RESOLVED', 'CANCELLED')),
|
||||
CONSTRAINT message_action_resolution_check CHECK (
|
||||
(status = 'PENDING' AND resolved_game_tick IS NULL)
|
||||
OR (status <> 'PENDING' AND resolved_game_tick IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Existing actionable payloads used message ticks as their GAME_TIME
|
||||
-- authority. Backfill once; after this migration message_action is authoritative
|
||||
-- and NULL never changes the clock domain of the rule.
|
||||
INSERT INTO message_action (
|
||||
message_id,
|
||||
action_type,
|
||||
status,
|
||||
created_game_tick,
|
||||
expires_game_tick,
|
||||
resolved_game_tick,
|
||||
clock_revision,
|
||||
deadline_generation
|
||||
)
|
||||
SELECT
|
||||
message.id,
|
||||
message.message->'option'->>'action',
|
||||
CASE
|
||||
WHEN message.time_tick IS NULL
|
||||
OR (message.valid_until < TIMESTAMP '9000-01-01' AND message.valid_until_tick IS NULL)
|
||||
OR lower(COALESCE(message.message->'option'->>'used', 'false')) IN ('true', '1')
|
||||
OR lower(COALESCE(message.message->'option'->>'invalid', 'false')) IN ('true', '1')
|
||||
OR message.valid_until <= message.time
|
||||
THEN 'RESOLVED'
|
||||
ELSE 'PENDING'
|
||||
END,
|
||||
COALESCE(message.time_tick, 0),
|
||||
CASE
|
||||
WHEN message.valid_until_tick IS NULL
|
||||
OR message.valid_until_tick >= 9007199254740991
|
||||
THEN NULL
|
||||
ELSE message.valid_until_tick
|
||||
END,
|
||||
CASE
|
||||
WHEN message.time_tick IS NULL
|
||||
OR (message.valid_until < TIMESTAMP '9000-01-01' AND message.valid_until_tick IS NULL)
|
||||
OR lower(COALESCE(message.message->'option'->>'used', 'false')) IN ('true', '1')
|
||||
OR lower(COALESCE(message.message->'option'->>'invalid', 'false')) IN ('true', '1')
|
||||
OR message.valid_until <= message.time
|
||||
THEN COALESCE(message.valid_until_tick, message.time_tick, 0)
|
||||
ELSE NULL
|
||||
END,
|
||||
COALESCE((SELECT clock_revision FROM world_state ORDER BY id ASC LIMIT 1), 1),
|
||||
COALESCE((SELECT deadline_generation FROM world_state ORDER BY id ASC LIMIT 1), 1)
|
||||
FROM message
|
||||
WHERE jsonb_typeof(message.message->'option') = 'object'
|
||||
AND NULLIF(message.message->'option'->>'action', '') IS NOT NULL;
|
||||
|
||||
CREATE INDEX message_action_status_expires_game_tick_idx
|
||||
ON message_action(status, expires_game_tick);
|
||||
|
||||
-- Inheritance requests are WALL_TIME receipts. Their input_event row remains
|
||||
-- the durable command/effect state and owns the GAME clock fence coordinate.
|
||||
CREATE TABLE inheritance_ledger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
request_id TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
cost DOUBLE PRECISION NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'APPLIED',
|
||||
requested_at_wall TIMESTAMP(3) NOT NULL,
|
||||
consumed_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
applied_clock_revision BIGINT NOT NULL,
|
||||
applied_deadline_generation BIGINT NOT NULL,
|
||||
created_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT inheritance_ledger_status_check CHECK (status IN ('APPLIED')),
|
||||
CONSTRAINT inheritance_ledger_cost_check CHECK (cost >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX inheritance_ledger_user_id_id_idx ON inheritance_ledger(user_id, id);
|
||||
CREATE INDEX inheritance_ledger_status_id_idx ON inheritance_ledger(status, id);
|
||||
|
||||
-- Auction bid receipt and gameplay occurrence are different facts. event_at is
|
||||
-- retained as the GAME_TIME projection used by existing UI and ordering code.
|
||||
ALTER TABLE auction_bid
|
||||
ADD COLUMN requested_at_wall TIMESTAMP(3),
|
||||
ADD COLUMN occurred_game_tick BIGINT;
|
||||
|
||||
UPDATE auction_bid AS bid
|
||||
SET requested_at_wall = bid.created_at,
|
||||
occurred_game_tick = ROUND(
|
||||
EXTRACT(EPOCH FROM (bid.event_at - world.clock_base_time))
|
||||
* (36000000::numeric / world.tick_seconds)
|
||||
)::bigint
|
||||
FROM world_state AS world;
|
||||
|
||||
ALTER TABLE auction_bid
|
||||
ALTER COLUMN requested_at_wall SET NOT NULL,
|
||||
ALTER COLUMN occurred_game_tick SET NOT NULL,
|
||||
ALTER COLUMN created_at SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC');
|
||||
|
||||
CREATE INDEX auction_bid_auction_occurred_game_tick_idx
|
||||
ON auction_bid(auction_id, occurred_game_tick);
|
||||
|
||||
-- Selection-pool reselection is expressed in turns. Preserve the old DateTime
|
||||
-- keys only as projections and make one GAME_TIME authority explicit.
|
||||
UPDATE general AS actor
|
||||
SET meta = jsonb_set(
|
||||
actor.meta,
|
||||
'{next_change_tick}',
|
||||
to_jsonb(ROUND(
|
||||
EXTRACT(EPOCH FROM (
|
||||
COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt')::timestamp
|
||||
- world.clock_base_time
|
||||
)) * (36000000::numeric / world.tick_seconds)
|
||||
)::bigint),
|
||||
true
|
||||
)
|
||||
FROM world_state AS world
|
||||
WHERE COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt') IS NOT NULL
|
||||
AND COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt')
|
||||
~ '^\d{4}-\d{2}-\d{2}T';
|
||||
@@ -48,6 +48,16 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여
|
||||
- `select_npc_token`, `select_npc_token_valid_until_idx`
|
||||
- `general_user_id_key`
|
||||
|
||||
## 시간 도메인 populated upgrade 검증
|
||||
|
||||
메시지 envelope의 WALL_TIME, actionable message와 선택 cooldown·경매의
|
||||
GAME_TIME backfill, 유산 receipt table, 두 번째 deploy no-op을 전용 tmpfs
|
||||
PostgreSQL에서 검증합니다. 영속 Docker volume은 만들지 않습니다.
|
||||
|
||||
```sh
|
||||
pnpm --filter @sammo-ts/infra verify:migration:time-domains
|
||||
```
|
||||
|
||||
검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유
|
||||
database나 Compose volume을 삭제하지 않습니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
package_dir="$(dirname "$script_dir")"
|
||||
prisma_dir="$package_dir/prisma"
|
||||
target_migration=20260903140000_split_message_wall_and_game_time
|
||||
task_label=devsam.core2026.time-domain-migration-preflight
|
||||
run_id="$(date -u +%m%d%H%M%S)_$$"
|
||||
container_name="sammo-time-domain-preflight-$run_id"
|
||||
schema_name="time_domain_preflight_$run_id"
|
||||
work_dir="$(mktemp -d /tmp/sammo-time-domain-preflight.XXXXXX)"
|
||||
container_created=0
|
||||
|
||||
case "$container_name" in sammo-time-domain-preflight-[0-9]*_[0-9]*) ;; *) exit 64 ;; esac
|
||||
case "$schema_name" in time_domain_preflight_[0-9]*_[0-9]*) ;; *) exit 64 ;; esac
|
||||
|
||||
cleanup() {
|
||||
cleanup_failed=0
|
||||
if [ "$container_created" -eq 1 ] && docker inspect "$container_name" >/dev/null 2>&1; then
|
||||
actual_label="$(docker inspect --format '{{ index .Config.Labels "devsam.core2026.task" }}' "$container_name")"
|
||||
if [ "$actual_label" != "$task_label" ]; then
|
||||
echo "refusing to remove container with unexpected ownership label" >&2
|
||||
cleanup_failed=1
|
||||
elif ! docker rm -f "$container_name" >/dev/null; then
|
||||
cleanup_failed=1
|
||||
fi
|
||||
fi
|
||||
case "$work_dir" in
|
||||
/tmp/sammo-time-domain-preflight.*) rm -r -- "$work_dir" || cleanup_failed=1 ;;
|
||||
*) cleanup_failed=1 ;;
|
||||
esac
|
||||
return "$cleanup_failed"
|
||||
}
|
||||
handle_exit() {
|
||||
exit_status=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if ! cleanup && [ "$exit_status" -eq 0 ]; then exit_status=1; fi
|
||||
exit "$exit_status"
|
||||
}
|
||||
trap handle_exit EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 69; }
|
||||
[ -d "$prisma_dir/migrations/$target_migration" ] || { echo "target migration is missing" >&2; exit 66; }
|
||||
|
||||
umask 077
|
||||
password="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
password_file="$work_dir/postgres_password"
|
||||
printf '%s\n' "$password" >"$password_file"
|
||||
|
||||
docker run -d \
|
||||
--name "$container_name" \
|
||||
--label "devsam.core2026.task=$task_label" \
|
||||
--tmpfs /var/lib/postgresql:rw,nodev,nosuid,size=1g \
|
||||
--mount "type=bind,source=$password_file,target=/run/secrets/postgres_password,readonly" \
|
||||
-e POSTGRES_DB=sammo \
|
||||
-e POSTGRES_USER=sammo \
|
||||
-e POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \
|
||||
-p 127.0.0.1::5432 \
|
||||
postgres:18.4-bookworm >/dev/null
|
||||
container_created=1
|
||||
|
||||
if [ -n "$(docker inspect --format '{{ range .Mounts }}{{ if eq .Type "volume" }}volume{{ end }}{{ end }}' "$container_name")" ]; then
|
||||
echo "preflight container unexpectedly owns a Docker volume" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
attempt=0
|
||||
until docker exec "$container_name" pg_isready -U sammo -d sammo >/dev/null 2>&1; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 60 ]; then docker logs --tail 100 "$container_name" >&2; exit 1; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
published_port="$(docker port "$container_name" 5432/tcp)"
|
||||
published_port="${published_port##*:}"
|
||||
case "$published_port" in ''|*[!0-9]*) exit 1 ;; esac
|
||||
|
||||
export POSTGRES_HOST=127.0.0.1
|
||||
export POSTGRES_PORT="$published_port"
|
||||
export POSTGRES_DB=sammo
|
||||
export POSTGRES_USER=sammo
|
||||
export POSTGRES_PASSWORD="$password"
|
||||
export POSTGRES_SCHEMA="$schema_name"
|
||||
unset DATABASE_URL DATABASE_SCHEMA
|
||||
|
||||
stage_prisma="$work_dir/prisma"
|
||||
mkdir -p "$stage_prisma/migrations"
|
||||
cp "$prisma_dir/game.prisma" "$stage_prisma/game.prisma"
|
||||
found_target=0
|
||||
for migration_dir in "$prisma_dir"/migrations/[0-9]*; do
|
||||
migration_name="$(basename "$migration_dir")"
|
||||
if [ "$migration_name" = "$target_migration" ]; then found_target=1; break; fi
|
||||
cp -R "$migration_dir" "$stage_prisma/migrations/$migration_name"
|
||||
done
|
||||
[ "$found_target" -eq 1 ] || exit 1
|
||||
|
||||
cd "$package_dir"
|
||||
PRISMA_SCHEMA="$stage_prisma/game.prisma" \
|
||||
pnpm exec prisma migrate deploy --schema "$stage_prisma/game.prisma" >"$work_dir/predecessor.log"
|
||||
|
||||
docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo >/dev/null <<SQL
|
||||
SET search_path TO "$schema_name";
|
||||
INSERT INTO world_state (
|
||||
scenario_code, current_year, current_month, tick_seconds,
|
||||
clock_base_time, clock_tick, clock_wall_anchor, last_turn_tick, updated_at
|
||||
) VALUES (
|
||||
'time-domain-fixture', 200, 1, 600,
|
||||
TIMESTAMP '0200-01-01 00:00:00', 36000000, TIMESTAMP '2026-09-03 00:00:00', 36000000,
|
||||
TIMESTAMP '2026-09-03 00:00:00'
|
||||
);
|
||||
INSERT INTO general (id, name, turn_time, meta)
|
||||
VALUES (
|
||||
910001,
|
||||
'시간장수',
|
||||
TIMESTAMP '0200-01-01 00:10:00',
|
||||
jsonb_build_object(
|
||||
'next_change', '0200-01-01T00:30:00.000Z',
|
||||
'nextChangeAt', '0200-01-01T00:30:00.000Z'
|
||||
)
|
||||
);
|
||||
INSERT INTO message (id, mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
|
||||
VALUES
|
||||
(920001, 0, 'global', 1, 0, TIMESTAMP '0200-01-01 00:00:00', 36000000,
|
||||
TIMESTAMP '9999-12-31 00:00:00', 9007199254740991,
|
||||
jsonb_build_object('text', 'normal')),
|
||||
(920002, 1, 'private', 1, 2, TIMESTAMP '0200-01-01 00:05:00', 54000000,
|
||||
TIMESTAMP '0200-01-01 01:00:00', 252000000,
|
||||
jsonb_build_object('option', jsonb_build_object('action', 'raiseInvader', 'used', false))),
|
||||
(920003, 2, 'private', 1, 2, TIMESTAMP '0200-01-01 00:06:00', NULL,
|
||||
TIMESTAMP '0200-01-01 01:00:00', NULL,
|
||||
jsonb_build_object('option', jsonb_build_object('action', 'scout', 'used', false)));
|
||||
INSERT INTO auction (id, type, host_general_id, status, close_at, open_tick, close_tick)
|
||||
VALUES (930001, 'UNIQUE_ITEM', 910001, 'OPEN', TIMESTAMP '0200-01-01 01:00:00', 36000000, 252000000);
|
||||
INSERT INTO auction_bid (id, auction_id, general_id, amount, event_id, event_at, created_at)
|
||||
VALUES (930002, 930001, 910001, 100, 'time-domain-bid', TIMESTAMP '0200-01-01 00:10:00',
|
||||
TIMESTAMP '2026-09-03 01:02:03.456');
|
||||
SQL
|
||||
|
||||
PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$work_dir/target.log"
|
||||
PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$work_dir/noop.log"
|
||||
grep -Fq 'No pending migrations to apply' "$work_dir/noop.log"
|
||||
|
||||
result="$(docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo -tAc "
|
||||
SET search_path TO \"$schema_name\";
|
||||
SELECT
|
||||
(SELECT count(*) FROM message_action) = 2
|
||||
AND (SELECT action_type = 'raiseInvader' AND status = 'PENDING' AND expires_game_tick = 252000000
|
||||
FROM message_action WHERE message_id = 920002)
|
||||
AND (SELECT status = 'RESOLVED' AND resolved_game_tick = 0
|
||||
FROM message_action WHERE message_id = 920003)
|
||||
AND (SELECT requested_at_wall = TIMESTAMP '2026-09-03 01:02:03.456'
|
||||
AND occurred_game_tick = 36000000
|
||||
FROM auction_bid WHERE id = 930002)
|
||||
AND (SELECT (meta->>'next_change_tick')::bigint = 108000000 FROM general WHERE id = 910001)
|
||||
AND (SELECT delete_until_wall <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC' FROM message WHERE id = 920001)
|
||||
AND to_regclass('\"$schema_name\".inheritance_ledger') IS NOT NULL;
|
||||
" | tail -n 1)"
|
||||
[ "$result" = "t" ] || { echo "time-domain migration assertions failed: $result" >&2; exit 1; }
|
||||
|
||||
echo "time-domain populated migration and no-op redeploy passed"
|
||||
@@ -13,6 +13,7 @@ export interface DatabaseClient {
|
||||
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
|
||||
messageReadState: GamePrisma.MessageReadStateDelegate;
|
||||
message: GamePrisma.MessageDelegate;
|
||||
messageAction: GamePrisma.MessageActionDelegate;
|
||||
city: GamePrisma.CityDelegate;
|
||||
nation: GamePrisma.NationDelegate;
|
||||
diplomacy: GamePrisma.DiplomacyDelegate;
|
||||
@@ -38,6 +39,7 @@ export interface DatabaseClient {
|
||||
nationBetting: GamePrisma.NationBettingDelegate;
|
||||
nationBet: GamePrisma.NationBetDelegate;
|
||||
inheritanceLog: GamePrisma.InheritanceLogDelegate;
|
||||
inheritanceLedger: GamePrisma.InheritanceLedgerDelegate;
|
||||
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
||||
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
|
||||
boardPost: GamePrisma.BoardPostDelegate;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user