시간 도메인과 정지 중 메시지·베팅 경계 정리
This commit is contained in:
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
|
|||||||
generalId: number,
|
generalId: number,
|
||||||
input: OpenAuctionInput,
|
input: OpenAuctionInput,
|
||||||
requestId?: string
|
requestId?: string
|
||||||
): Promise<{ auctionId: number; closeAt: string }> => {
|
): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'auctionOpen',
|
type: 'auctionOpen',
|
||||||
...(requestId ? { requestId } : {}),
|
...(requestId ? { requestId } : {}),
|
||||||
@@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async (
|
|||||||
const closeAt = new Date(result.closeAt);
|
const closeAt = new Date(result.closeAt);
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
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 {
|
return {
|
||||||
auctionId: result.auctionId,
|
auctionId: result.auctionId,
|
||||||
closeAt: result.closeAt,
|
closeAt: result.closeAt,
|
||||||
|
closeTick: result.closeTick,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,18 +10,19 @@ interface RedisSortedSetClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
|
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
|
||||||
if (closeTick !== null && closeTick !== undefined) {
|
void time;
|
||||||
|
void closeAt;
|
||||||
|
if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
|
||||||
const value = Number(closeTick);
|
const value = Number(closeTick);
|
||||||
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||||
return value;
|
return value;
|
||||||
}
|
|
||||||
return time.dateToTick(closeAt) ?? closeAt.getTime();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
|
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
|
||||||
if (row.status === 'FINALIZING') {
|
if (row.status === 'FINALIZING') {
|
||||||
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
|
// 마감 판정은 이미 끝났으므로 원래 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);
|
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
|
|||||||
|
|
||||||
interface AuctionFinalizeDeadline {
|
interface AuctionFinalizeDeadline {
|
||||||
closeAt: Date;
|
closeAt: Date;
|
||||||
closeTick: bigint | null;
|
closeTick: bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuctionFinalizeCommand {
|
interface AuctionFinalizeCommand {
|
||||||
@@ -56,19 +56,10 @@ interface AuctionFinalizeCommand {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
auctionId: number;
|
auctionId: number;
|
||||||
expectedCloseAt: string;
|
expectedCloseAt: string;
|
||||||
expectedCloseTick?: number;
|
expectedCloseTick: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuctionFinalizeEventRecord {
|
const readSafeCloseTick = (closeTick: bigint): number => {
|
||||||
target: string;
|
|
||||||
eventType: string;
|
|
||||||
payload: unknown;
|
|
||||||
status: string;
|
|
||||||
result: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
|
|
||||||
if (closeTick === null) return undefined;
|
|
||||||
const value = Number(closeTick);
|
const value = Number(closeTick);
|
||||||
if (!Number.isSafeInteger(value)) {
|
if (!Number.isSafeInteger(value)) {
|
||||||
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||||
@@ -81,14 +72,7 @@ export const buildAuctionFinalizeRequestId = (
|
|||||||
deadline: AuctionFinalizeDeadline,
|
deadline: AuctionFinalizeDeadline,
|
||||||
retry = 0
|
retry = 0
|
||||||
): string => {
|
): string => {
|
||||||
const generation =
|
const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
|
||||||
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()}`;
|
|
||||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,7 +85,7 @@ const buildAuctionFinalizeCommand = (
|
|||||||
requestId,
|
requestId,
|
||||||
auctionId,
|
auctionId,
|
||||||
expectedCloseAt: deadline.closeAt.toISOString(),
|
expectedCloseAt: deadline.closeAt.toISOString(),
|
||||||
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }),
|
expectedCloseTick: readSafeCloseTick(deadline.closeTick),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMatchingAuctionFinalizeEvent = (
|
const isMatchingAuctionFinalizeEvent = (
|
||||||
@@ -113,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = (
|
|||||||
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
||||||
? (payload as Record<string, unknown>)
|
? (payload as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
const expectedGenerationMatches =
|
const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
|
||||||
payloadRecord?.expectedCloseTick !== undefined
|
|
||||||
? payloadRecord.expectedCloseTick === command.expectedCloseTick
|
|
||||||
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
|
|
||||||
return (
|
return (
|
||||||
event.target === 'ENGINE' &&
|
event.target === 'ENGINE' &&
|
||||||
event.eventType === command.type &&
|
event.eventType === command.type &&
|
||||||
@@ -192,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: {
|
|||||||
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
|
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
|
||||||
continue;
|
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 deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
|
||||||
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
|
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({
|
const events = await options.db.inputEvent.findMany({
|
||||||
where: {
|
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 },
|
select: { requestId: true, target: true, eventType: true, payload: true, status: true },
|
||||||
orderBy: { sequence: 'desc' },
|
orderBy: { sequence: 'desc' },
|
||||||
@@ -217,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: {
|
|||||||
timers.push({
|
timers.push({
|
||||||
score:
|
score:
|
||||||
row.status === 'FINALIZING'
|
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),
|
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
|
||||||
value: String(row.id),
|
value: String(row.id),
|
||||||
});
|
});
|
||||||
@@ -281,10 +264,10 @@ export const processDueAuctionId = async (options: {
|
|||||||
return { status: 'IGNORED' as const };
|
return { status: 'IGNORED' as const };
|
||||||
}
|
}
|
||||||
if (current.status === 'OPEN') {
|
if (current.status === 'OPEN') {
|
||||||
const isDue =
|
if (current.closeTick === null || nowTick === null) {
|
||||||
current.closeTick !== null && nowTick !== null
|
throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
|
||||||
? current.closeTick <= BigInt(nowTick)
|
}
|
||||||
: current.closeTick === null && current.closeAt.getTime() <= now.getTime();
|
const isDue = current.closeTick <= BigInt(nowTick);
|
||||||
if (!isDue) {
|
if (!isDue) {
|
||||||
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
|
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 };
|
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 };
|
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
|
||||||
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
|
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
|
||||||
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
|
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
|
||||||
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
|
const existing = await transaction.inputEvent.findUnique({
|
||||||
const candidateRequestIds = [...new Set([requestId, legacyRequestId])];
|
where: { requestId },
|
||||||
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 },
|
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
||||||
});
|
});
|
||||||
if (existing) {
|
const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||||
existingRequestId = candidateRequestId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||||
await transaction.inputEvent.create({
|
await transaction.inputEvent.create({
|
||||||
@@ -319,26 +293,19 @@ export const processDueAuctionId = async (options: {
|
|||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
eventType: nextCommand.type,
|
eventType: nextCommand.type,
|
||||||
payload: { ...nextCommand },
|
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 };
|
return { status: 'PENDING' as const };
|
||||||
}
|
}
|
||||||
if (!isMatchingAuctionFinalizeEvent(existing, command)) {
|
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') {
|
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
|
||||||
return { status: 'PENDING' as const };
|
return { status: 'PENDING' as const };
|
||||||
}
|
}
|
||||||
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
|
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
|
||||||
throw new Error(
|
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() },
|
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let nextResyncAt = Date.now();
|
let nextResyncAt = performance.now();
|
||||||
const pendingFinalizationIds = new Set<number>();
|
const pendingFinalizationIds = new Set<number>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (!control.signal.aborted) {
|
while (!control.signal.aborted) {
|
||||||
const operationalNowMs = Date.now();
|
const operationalNowMs = Date.now();
|
||||||
|
const operationalElapsedMs = performance.now();
|
||||||
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
|
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
|
||||||
const gameNowMs = gameTime.now.getTime();
|
const gameNowMs = gameTime.now.getTime();
|
||||||
const dueScore = gameTime.tick ?? gameNowMs;
|
const dueScore = gameTime.tick ?? gameNowMs;
|
||||||
@@ -406,9 +374,9 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
|||||||
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
|
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (operationalNowMs >= nextResyncAt) {
|
if (operationalElapsedMs >= nextResyncAt) {
|
||||||
await seedAuctionTimers(postgres.prisma, redis.client, keys);
|
await seedAuctionTimers(postgres.prisma, redis.client, keys);
|
||||||
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
|
nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
|
||||||
}
|
}
|
||||||
if (pendingFinalizationIds.size > 0) {
|
if (pendingFinalizationIds.size > 0) {
|
||||||
const reconciliation = await reconcilePendingAuctionTimers({
|
const reconciliation = await reconcilePendingAuctionTimers({
|
||||||
@@ -483,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
|||||||
await closeResources();
|
await closeResources();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
readInputEventClockCoordinate,
|
readInputEventClockCoordinate,
|
||||||
type DatabaseClient,
|
type DatabaseClient,
|
||||||
type GamePrisma,
|
type GamePrisma,
|
||||||
type InputEventClockCoordinate,
|
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { TurnDaemonTransport } from './transport.js';
|
import type { TurnDaemonTransport } from './transport.js';
|
||||||
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.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;
|
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> {
|
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
||||||
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
||||||
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
||||||
if (durableCommand.type === 'npcPossessGeneral') {
|
// Rolling-upgrade compatibility: older API versions supplied game
|
||||||
delete durableCommand.acceptedGameAt;
|
// 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') {
|
if (command.type === 'npcPossessGeneral') {
|
||||||
const existing = await this.db.inputEvent.findUnique({
|
const existing = await this.db.inputEvent.findUnique({
|
||||||
where: { requestId },
|
where: { requestId },
|
||||||
@@ -112,12 +114,11 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
const coordinate = await readInputEventClockCoordinate(transaction);
|
const coordinate = await readInputEventClockCoordinate(transaction);
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
|
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
|
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
|
||||||
const acceptedGameAt = coordinate.gameAt;
|
|
||||||
const token = await transaction.npcSelectionToken.findFirst({
|
const token = await transaction.npcSelectionToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
ownerUserId: command.userId,
|
ownerUserId: command.userId,
|
||||||
nonce: command.tokenNonce,
|
nonce: command.tokenNonce,
|
||||||
validUntil: { gte: acceptedGameAt },
|
validUntilTick: { gte: coordinate.gameTick },
|
||||||
},
|
},
|
||||||
select: { pickResult: true },
|
select: { pickResult: true },
|
||||||
});
|
});
|
||||||
@@ -132,11 +133,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
) {
|
) {
|
||||||
return '선택한 장수가 목록에 없습니다.';
|
return '선택한 장수가 목록에 없습니다.';
|
||||||
}
|
}
|
||||||
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = {
|
await this.createInputEvent(transaction, durableCommand, requestId);
|
||||||
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
|
|
||||||
acceptedGameAt: acceptedGameAt.toISOString(),
|
|
||||||
};
|
|
||||||
await this.createInputEvent(transaction, acceptedCommand, requestId, coordinate);
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (rejectionReason) {
|
if (rejectionReason) {
|
||||||
@@ -145,8 +142,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
} else {
|
} else {
|
||||||
if (this.db.$transaction) {
|
if (this.db.$transaction) {
|
||||||
await this.db.$transaction(async (transaction) => {
|
await this.db.$transaction(async (transaction) => {
|
||||||
const coordinate = await readInputEventClockCoordinate(transaction);
|
await this.createInputEvent(transaction, durableCommand, requestId);
|
||||||
await this.createInputEvent(transaction, durableCommand, requestId, coordinate);
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.createInputEvent(this.db, durableCommand, requestId);
|
await this.createInputEvent(this.db, durableCommand, requestId);
|
||||||
@@ -178,21 +174,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
private async createInputEvent(
|
private async createInputEvent(
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
command: TurnDaemonCommand,
|
command: TurnDaemonCommand,
|
||||||
requestId: string,
|
requestId: string
|
||||||
coordinate?: InputEventClockCoordinate
|
|
||||||
): Promise<void> {
|
): 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({
|
await db.inputEvent.create({
|
||||||
data: {
|
data: {
|
||||||
requestId,
|
requestId,
|
||||||
@@ -200,14 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
eventType: command.type,
|
eventType: command.type,
|
||||||
payload: asJson(command),
|
payload: asJson(command),
|
||||||
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
|
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
|
||||||
...(acceptedGameTick === null ? {} : { acceptedGameTick: BigInt(acceptedGameTick) }),
|
// PostgreSQL owns created_at WALL_TIME. ENGINE assigns the
|
||||||
...(acceptedClockRevision === null || acceptedClockRevision === undefined
|
// authoritative game coordinate when the daemon claims it.
|
||||||
? {}
|
|
||||||
: { acceptedClockRevision: BigInt(acceptedClockRevision) }),
|
|
||||||
...(acceptedDeadlineGeneration === null || acceptedDeadlineGeneration === undefined
|
|
||||||
? {}
|
|
||||||
: { acceptedDeadlineGeneration: BigInt(acceptedDeadlineGeneration) }),
|
|
||||||
...(coordinate ? { createdAt: coordinate.wallAt } : {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -224,8 +201,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
|
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
|
||||||
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
|
const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
|
||||||
while (Date.now() < deadline) {
|
while (performance.now() < deadline) {
|
||||||
const event = await this.db.inputEvent.findUnique({
|
const event = await this.db.inputEvent.findUnique({
|
||||||
where: { requestId },
|
where: { requestId },
|
||||||
select: { status: true, result: true, error: true },
|
select: { status: true, result: true, error: true },
|
||||||
@@ -236,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
if (event?.status === 'FAILED') {
|
if (event?.status === 'FAILED') {
|
||||||
throw new FailedTurnDaemonCommandError(requestId, event.error);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ interface LockedInputEvent {
|
|||||||
status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED';
|
status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED';
|
||||||
result: GamePrisma.JsonValue | null;
|
result: GamePrisma.JsonValue | null;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
acceptedGameTick: bigint | null;
|
|
||||||
acceptedClockRevision: bigint | null;
|
|
||||||
acceptedDeadlineGeneration: bigint | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type InputEventOutcome<T> =
|
type InputEventOutcome<T> =
|
||||||
@@ -37,8 +34,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
|
|||||||
$executeRawUnsafe(query: string): Promise<number>;
|
$executeRawUnsafe(query: string): Promise<number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
|
||||||
|
|
||||||
const canonicalJson = (value: unknown): string =>
|
const canonicalJson = (value: unknown): string =>
|
||||||
JSON.stringify(value, (_key, entry: unknown) => {
|
JSON.stringify(value, (_key, entry: unknown) => {
|
||||||
if (typeof entry === 'bigint') {
|
if (typeof entry === 'bigint') {
|
||||||
@@ -106,9 +101,9 @@ const insertPendingIfAbsent = async (
|
|||||||
${options.actorUserId},
|
${options.actorUserId},
|
||||||
'PENDING'::"InputEventStatus",
|
'PENDING'::"InputEventStatus",
|
||||||
0,
|
0,
|
||||||
(SELECT clock_tick FROM world_state ORDER BY id ASC LIMIT 1),
|
NULL,
|
||||||
(SELECT clock_revision FROM world_state ORDER BY id ASC LIMIT 1),
|
NULL,
|
||||||
(SELECT deadline_generation FROM world_state ORDER BY id ASC LIMIT 1),
|
NULL,
|
||||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
)
|
)
|
||||||
ON CONFLICT (request_id) DO NOTHING
|
ON CONFLICT (request_id) DO NOTHING
|
||||||
@@ -126,10 +121,7 @@ const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise<Lo
|
|||||||
actor_user_id AS "actorUserId",
|
actor_user_id AS "actorUserId",
|
||||||
status,
|
status,
|
||||||
result,
|
result,
|
||||||
attempts,
|
attempts
|
||||||
accepted_game_tick AS "acceptedGameTick",
|
|
||||||
accepted_clock_revision AS "acceptedClockRevision",
|
|
||||||
accepted_deadline_generation AS "acceptedDeadlineGeneration"
|
|
||||||
FROM input_event
|
FROM input_event
|
||||||
WHERE request_id = ${requestId}
|
WHERE request_id = ${requestId}
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -168,26 +160,24 @@ const isMatchingIdentity = (
|
|||||||
const claimInputEvent = async (
|
const claimInputEvent = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
payloadIdentity: ApiInputPayloadIdentity,
|
payloadIdentity: ApiInputPayloadIdentity
|
||||||
row: LockedInputEvent
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await db.inputEvent.update({
|
await db.$executeRaw(GamePrisma.sql`
|
||||||
where: { requestId },
|
UPDATE input_event
|
||||||
data: {
|
SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
|
||||||
payload: asJson(payloadIdentity),
|
status = 'PROCESSING'::"InputEventStatus",
|
||||||
status: 'PROCESSING',
|
result = NULL,
|
||||||
result: GamePrisma.DbNull,
|
error = NULL,
|
||||||
error: null,
|
attempts = attempts + 1,
|
||||||
attempts: { increment: 1 },
|
locked_by = NULL,
|
||||||
lockedBy: null,
|
lease_until = NULL,
|
||||||
leaseUntil: null,
|
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
processingAt: new Date(),
|
processing_game_tick = NULL,
|
||||||
processingGameTick: row.acceptedGameTick,
|
processing_clock_revision = NULL,
|
||||||
processingClockRevision: row.acceptedClockRevision,
|
processing_deadline_generation = NULL,
|
||||||
processingDeadlineGeneration: row.acceptedDeadlineGeneration,
|
completed_at = NULL
|
||||||
completedAt: null,
|
WHERE request_id = ${requestId}
|
||||||
},
|
`);
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const markUnexpectedFailure = async (
|
const markUnexpectedFailure = async (
|
||||||
@@ -198,6 +188,7 @@ const markUnexpectedFailure = async (
|
|||||||
actorUserId: string | null;
|
actorUserId: string | null;
|
||||||
payloadIdentity: ApiInputPayloadIdentity;
|
payloadIdentity: ApiInputPayloadIdentity;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
|
acquireClockFence: boolean;
|
||||||
}
|
}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!db.$transaction) return;
|
if (!db.$transaction) return;
|
||||||
@@ -205,7 +196,9 @@ const markUnexpectedFailure = async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await db.$transaction(async (transaction) => {
|
await db.$transaction(async (transaction) => {
|
||||||
|
if (options.acquireClockFence) {
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||||
|
}
|
||||||
await insertPendingIfAbsent(transaction, options);
|
await insertPendingIfAbsent(transaction, options);
|
||||||
const row = await lockInputEvent(transaction, options.requestId);
|
const row = await lockInputEvent(transaction, options.requestId);
|
||||||
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
|
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
|
||||||
@@ -214,20 +207,19 @@ const markUnexpectedFailure = async (
|
|||||||
// late failure recorder must never replace its durable success.
|
// late failure recorder must never replace its durable success.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await transaction.inputEvent.update({
|
await transaction.$executeRaw(GamePrisma.sql`
|
||||||
where: { requestId: options.requestId },
|
UPDATE input_event
|
||||||
data: {
|
SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
|
||||||
payload: asJson(options.payloadIdentity),
|
status = 'FAILED'::"InputEventStatus",
|
||||||
status: 'FAILED',
|
result = NULL,
|
||||||
result: GamePrisma.DbNull,
|
error = ${message},
|
||||||
error: message,
|
attempts = attempts + 1,
|
||||||
attempts: { increment: 1 },
|
locked_by = NULL,
|
||||||
lockedBy: null,
|
lease_until = NULL,
|
||||||
leaseUntil: null,
|
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
processingAt: new Date(),
|
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
completedAt: new Date(),
|
WHERE request_id = ${options.requestId}
|
||||||
},
|
`);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Preserve the transaction failure that the caller actually observed. If
|
// Preserve the transaction failure that the caller actually observed. If
|
||||||
@@ -242,10 +234,12 @@ export const executeInputEvent = async <T>(options: {
|
|||||||
eventType: string;
|
eventType: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
actorUserId?: string | null;
|
actorUserId?: string | null;
|
||||||
|
acquireClockFence?: boolean;
|
||||||
execute(db: DatabaseClient): Promise<T>;
|
execute(db: DatabaseClient): Promise<T>;
|
||||||
}): Promise<T> => {
|
}): Promise<T> => {
|
||||||
const { db, requestId, eventType, payload, execute } = options;
|
const { db, requestId, eventType, payload, execute } = options;
|
||||||
const actorUserId = options.actorUserId ?? null;
|
const actorUserId = options.actorUserId ?? null;
|
||||||
|
const acquireClockFence = options.acquireClockFence !== false;
|
||||||
const payloadIdentity = createApiInputPayloadIdentity(payload);
|
const payloadIdentity = createApiInputPayloadIdentity(payload);
|
||||||
if (!db.$transaction) {
|
if (!db.$transaction) {
|
||||||
return execute(db);
|
return execute(db);
|
||||||
@@ -255,7 +249,9 @@ export const executeInputEvent = async <T>(options: {
|
|||||||
let outcome: InputEventOutcome<T>;
|
let outcome: InputEventOutcome<T>;
|
||||||
try {
|
try {
|
||||||
outcome = await db.$transaction(async (transaction) => {
|
outcome = await db.$transaction(async (transaction) => {
|
||||||
|
if (acquireClockFence) {
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||||
|
}
|
||||||
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
|
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
|
||||||
const row = await lockInputEvent(transaction, requestId);
|
const row = await lockInputEvent(transaction, requestId);
|
||||||
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
|
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
|
||||||
@@ -274,43 +270,48 @@ export const executeInputEvent = async <T>(options: {
|
|||||||
throw new DuplicateInputEventError(requestId);
|
throw new DuplicateInputEventError(requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await claimInputEvent(transaction, requestId, payloadIdentity, row);
|
await claimInputEvent(transaction, requestId, payloadIdentity);
|
||||||
const savepointDb = transaction as SavepointDatabaseClient;
|
const savepointDb = transaction as SavepointDatabaseClient;
|
||||||
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||||
businessStarted = true;
|
businessStarted = true;
|
||||||
try {
|
try {
|
||||||
const value = await execute(transaction);
|
const value = await execute(transaction);
|
||||||
const durableResult = canonicalJsonValue(value);
|
const durableResult = canonicalJsonValue(value);
|
||||||
await transaction.inputEvent.update({
|
await transaction.$executeRaw(GamePrisma.sql`
|
||||||
where: { requestId },
|
UPDATE input_event
|
||||||
data: {
|
SET status = 'SUCCEEDED'::"InputEventStatus",
|
||||||
status: 'SUCCEEDED',
|
result = CAST(${JSON.stringify(durableResult)} AS jsonb),
|
||||||
result: asJson(durableResult),
|
error = NULL,
|
||||||
error: null,
|
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
completedAt: new Date(),
|
WHERE request_id = ${requestId}
|
||||||
},
|
`);
|
||||||
});
|
|
||||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||||
return { kind: 'executed', value };
|
return { kind: 'executed', value };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||||
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
|
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
|
||||||
await transaction.inputEvent.update({
|
await transaction.$executeRaw(GamePrisma.sql`
|
||||||
where: { requestId },
|
UPDATE input_event
|
||||||
data: {
|
SET status = 'FAILED'::"InputEventStatus",
|
||||||
status: 'FAILED',
|
result = NULL,
|
||||||
result: GamePrisma.DbNull,
|
error = ${message},
|
||||||
error: message,
|
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
completedAt: new Date(),
|
WHERE request_id = ${requestId}
|
||||||
},
|
`);
|
||||||
});
|
|
||||||
return { kind: 'failed', error };
|
return { kind: 'failed', error };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
|
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
|
||||||
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error });
|
await markUnexpectedFailure(db, {
|
||||||
|
requestId,
|
||||||
|
eventType,
|
||||||
|
actorUserId,
|
||||||
|
payloadIdentity,
|
||||||
|
error,
|
||||||
|
acquireClockFence,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
import {
|
||||||
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
enqueuePrivateMessageWebPush,
|
||||||
|
GamePrisma,
|
||||||
|
persistMessageEnvelope,
|
||||||
|
type MessageGameContext,
|
||||||
|
} from '@sammo-ts/infra';
|
||||||
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { DatabaseClient } from '../context.js';
|
import type { DatabaseClient } from '../context.js';
|
||||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||||
|
|
||||||
export interface MessageView {
|
export interface MessageView {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,7 +26,9 @@ interface MessageRow {
|
|||||||
src: number;
|
src: number;
|
||||||
dest: number;
|
dest: number;
|
||||||
time: Date;
|
time: Date;
|
||||||
valid_until: Date;
|
created_at_wall: Date;
|
||||||
|
action_status: string | null;
|
||||||
|
expires_game_tick: bigint | null;
|
||||||
message: unknown;
|
message: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,70 +54,66 @@ const formatMessageTime = (value: Date): string => {
|
|||||||
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const messageValidityPredicate = (gameTime: CurrentGameTime) => {
|
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
||||||
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 payload = parsePayload(row.message);
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
msgType: row.type,
|
msgType: row.type,
|
||||||
src: payload.src,
|
src: payload.src,
|
||||||
dest: row.type === 'public' ? null : payload.dest,
|
dest: row.type === 'public' ? null : payload.dest,
|
||||||
text: payload.text,
|
text: payload.text,
|
||||||
option: payload.option ?? null,
|
option:
|
||||||
time: formatMessageTime(new Date(row.time)),
|
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> => {
|
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
|
||||||
|
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);
|
const gameTime = await loadCurrentGameTime(db);
|
||||||
const toTickOrNull = (date: Date): bigint | null => {
|
if (
|
||||||
// Ref represents its unlimited 9999-12-31 message lifetime with the
|
gameTime.tick === null ||
|
||||||
// largest safe game tick instead of falling back to a wall-clock-only row.
|
gameTime.revision === null ||
|
||||||
if (date.getUTCFullYear() >= 9000) {
|
gameTime.revision === undefined ||
|
||||||
return BigInt(MAX_SAFE_GAME_TICK);
|
gameTime.deadlineGeneration === null ||
|
||||||
|
gameTime.deadlineGeneration === undefined
|
||||||
|
) {
|
||||||
|
throw new Error(`Actionable message ${action} requires an initialized game clock.`);
|
||||||
}
|
}
|
||||||
try {
|
let expiresGameTick: bigint | null = null;
|
||||||
const tick = gameTime.dateToTick(date);
|
if (draft.validUntil.getUTCFullYear() < 9000) {
|
||||||
return tick === null ? null : BigInt(tick);
|
const expires = gameTime.dateToTick(draft.validUntil);
|
||||||
} catch {
|
if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
|
||||||
return null;
|
expiresGameTick = BigInt(expires);
|
||||||
}
|
}
|
||||||
|
gameContext = {
|
||||||
|
occurredGameTick: BigInt(gameTime.tick),
|
||||||
|
clockRevision: BigInt(gameTime.revision),
|
||||||
|
deadlineGeneration: BigInt(gameTime.deadlineGeneration),
|
||||||
|
expiresGameTick,
|
||||||
};
|
};
|
||||||
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.');
|
|
||||||
}
|
}
|
||||||
|
const id = await persistMessageEnvelope(db, draft, gameContext);
|
||||||
await enqueuePrivateMessageWebPush(db, draft, id);
|
await enqueuePrivateMessageWebPush(db, draft, id);
|
||||||
return 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: {
|
export const fetchMessagesFromMailbox = async (params: {
|
||||||
db: DatabaseClient;
|
db: DatabaseClient;
|
||||||
mailbox: number;
|
mailbox: number;
|
||||||
@@ -120,19 +122,20 @@ export const fetchMessagesFromMailbox = async (params: {
|
|||||||
fromSeq: number;
|
fromSeq: number;
|
||||||
}): Promise<MessageView[]> => {
|
}): Promise<MessageView[]> => {
|
||||||
const fromSeq = Math.max(params.fromSeq, 0);
|
const fromSeq = Math.max(params.fromSeq, 0);
|
||||||
const gameTime = await loadCurrentGameTime(params.db);
|
|
||||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||||
FROM message
|
m.created_at_wall, m.message,
|
||||||
WHERE mailbox = ${params.mailbox}
|
ma.status AS action_status, ma.expires_game_tick
|
||||||
AND type = ${params.msgType}
|
FROM message m
|
||||||
AND ${messageValidityPredicate(gameTime)}
|
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||||
AND id >= ${fromSeq}
|
WHERE m.mailbox = ${params.mailbox}
|
||||||
ORDER BY id DESC
|
AND m.type = ${params.msgType}
|
||||||
|
AND m.id >= ${fromSeq}
|
||||||
|
ORDER BY m.id DESC
|
||||||
LIMIT ${params.limit}
|
LIMIT ${params.limit}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return rows.map(toMessageView);
|
return loadMessageViews(params.db, rows);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchOldMessagesFromMailbox = async (params: {
|
export const fetchOldMessagesFromMailbox = async (params: {
|
||||||
@@ -142,28 +145,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
|
|||||||
toSeq: number;
|
toSeq: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
}): Promise<MessageView[]> => {
|
}): Promise<MessageView[]> => {
|
||||||
const gameTime = await loadCurrentGameTime(params.db);
|
|
||||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||||
FROM message
|
m.created_at_wall, m.message,
|
||||||
WHERE mailbox = ${params.mailbox}
|
ma.status AS action_status, ma.expires_game_tick
|
||||||
AND type = ${params.msgType}
|
FROM message m
|
||||||
AND ${messageValidityPredicate(gameTime)}
|
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||||
AND id < ${params.toSeq}
|
WHERE m.mailbox = ${params.mailbox}
|
||||||
ORDER BY id DESC
|
AND m.type = ${params.msgType}
|
||||||
|
AND m.id < ${params.toSeq}
|
||||||
|
ORDER BY m.id DESC
|
||||||
LIMIT ${params.limit}
|
LIMIT ${params.limit}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return rows.map(toMessageView);
|
return loadMessageViews(params.db, rows);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||||
const gameTime = await loadCurrentGameTime(db);
|
|
||||||
const rows = await db.$queryRaw<MessageRow[]>`
|
const rows = await db.$queryRaw<MessageRow[]>`
|
||||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||||
FROM message
|
m.created_at_wall, m.message,
|
||||||
WHERE id = ${id}
|
ma.status AS action_status, ma.expires_game_tick
|
||||||
AND ${messageValidityPredicate(gameTime)}
|
FROM message m
|
||||||
|
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||||
|
WHERE m.id = ${id}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
@@ -172,20 +177,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
mailbox: row.mailbox,
|
mailbox: row.mailbox,
|
||||||
msgType: row.type,
|
msgType: row.type,
|
||||||
time: new Date(row.time),
|
time: new Date(row.created_at_wall ?? row.time),
|
||||||
payload: parsePayload(row.message),
|
payload: parsePayload(row.message),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||||
const gameTime = await loadCurrentGameTime(db);
|
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[]>`
|
const rows = await db.$queryRaw<MessageRow[]>`
|
||||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||||
FROM message
|
m.created_at_wall, m.message,
|
||||||
WHERE id = ${id}
|
ma.status AS action_status, ma.expires_game_tick
|
||||||
AND ${messageValidityPredicate(gameTime)}
|
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
|
LIMIT 1
|
||||||
FOR UPDATE
|
FOR UPDATE OF m, ma, world
|
||||||
`;
|
`;
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -193,7 +207,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
mailbox: row.mailbox,
|
mailbox: row.mailbox,
|
||||||
msgType: row.type,
|
msgType: row.type,
|
||||||
time: new Date(row.time),
|
time: new Date(row.created_at_wall ?? row.time),
|
||||||
payload: parsePayload(row.message),
|
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)));
|
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||||
if (uniqueIds.length === 0) return;
|
if (uniqueIds.length === 0) return;
|
||||||
const gameTime = await loadCurrentGameTime(db);
|
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({
|
await db.message.updateMany({
|
||||||
where: { id: { in: uniqueIds } },
|
where: { id: { in: uniqueIds } },
|
||||||
data: {
|
data: {
|
||||||
validUntil: gameTime.now,
|
validUntil: gameTime.now,
|
||||||
// A partially migrated profile can still carry a legacy logical
|
validUntilTick: BigInt(gameTime.tick),
|
||||||
// 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),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -233,8 +247,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
|
|||||||
END
|
END
|
||||||
) || jsonb_build_object('invalid', true),
|
) || jsonb_build_object('invalid', true),
|
||||||
true
|
true
|
||||||
)
|
),
|
||||||
|
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
WHERE id IN (${GamePrisma.join(uniqueIds)})
|
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 = (
|
export const hasAuctionClosePassed = (
|
||||||
auction: { closeAt: Date; closeTick: bigint | null },
|
auction: { closeAt: Date; closeTick: bigint | null },
|
||||||
time: { now: Date; tick: number | null }
|
time: { now: Date; tick: number | null }
|
||||||
): boolean =>
|
): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
|
||||||
auction.closeTick !== null && time.tick !== null
|
|
||||||
? auction.closeTick < BigInt(time.tick)
|
|
||||||
: auction.closeAt.getTime() < time.now.getTime();
|
|
||||||
|
|
||||||
interface AuctionBidRow {
|
interface AuctionBidRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -433,7 +430,6 @@ export const auctionRouter = router({
|
|||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
|
||||||
tryExtendCloseDate: true,
|
tryExtendCloseDate: true,
|
||||||
});
|
});
|
||||||
throwIfCommandRejected(result);
|
throwIfCommandRejected(result);
|
||||||
@@ -448,7 +444,7 @@ export const auctionRouter = router({
|
|||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
const nextCloseAt = new Date(result.closeAt);
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
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 };
|
return { ok: true };
|
||||||
@@ -511,7 +507,6 @@ export const auctionRouter = router({
|
|||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
|
||||||
tryExtendCloseDate: true,
|
tryExtendCloseDate: true,
|
||||||
});
|
});
|
||||||
throwIfCommandRejected(result);
|
throwIfCommandRejected(result);
|
||||||
@@ -526,7 +521,7 @@ export const auctionRouter = router({
|
|||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
const nextCloseAt = new Date(result.closeAt);
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
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 };
|
return { ok: true };
|
||||||
@@ -650,7 +645,6 @@ export const auctionRouter = router({
|
|||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
|
||||||
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||||
});
|
});
|
||||||
throwIfCommandRejected(result);
|
throwIfCommandRejected(result);
|
||||||
@@ -665,7 +659,7 @@ export const auctionRouter = router({
|
|||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
const nextCloseAt = new Date(result.closeAt);
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
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 };
|
return { ok: true };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
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 { z } from 'zod';
|
||||||
|
|
||||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||||
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
|
|||||||
return world;
|
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({
|
export const bettingRouter = router({
|
||||||
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
|
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
|
||||||
.query(async ({ ctx, input }) => {
|
async ({ ctx, input }) => {
|
||||||
requireUserId(ctx.auth);
|
requireUserId(ctx.auth);
|
||||||
await getMyGeneral(ctx);
|
await getMyGeneral(ctx);
|
||||||
const [world, rows] = await Promise.all([
|
const [world, rows] = await Promise.all([
|
||||||
@@ -66,7 +100,8 @@ export const bettingRouter = router({
|
|||||||
year: world.currentYear,
|
year: world.currentYear,
|
||||||
month: world.currentMonth,
|
month: world.currentMonth,
|
||||||
};
|
};
|
||||||
}),
|
}
|
||||||
|
),
|
||||||
|
|
||||||
getDetail: authedProcedure
|
getDetail: authedProcedure
|
||||||
.input(z.object({ bettingId: z.number().int().positive() }))
|
.input(z.object({ bettingId: z.number().int().positive() }))
|
||||||
@@ -141,7 +176,7 @@ export const bettingRouter = router({
|
|||||||
if (betting.finished) {
|
if (betting.finished) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
|
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);
|
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
|
||||||
if (betting.closeYearMonth <= yearMonth) {
|
if (betting.closeYearMonth <= yearMonth) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
||||||
import { insertMessage } from '../../messages/store.js';
|
import { insertMessage } from '../../messages/store.js';
|
||||||
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.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 { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||||
@@ -306,8 +306,6 @@ export const diplomacyRouter = router({
|
|||||||
nationColor: destNation.color,
|
nationColor: destNation.color,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
|
|
||||||
|
|
||||||
const created = await ctx.db.diplomacyLetter.create({
|
const created = await ctx.db.diplomacyLetter.create({
|
||||||
data: {
|
data: {
|
||||||
srcNationId: srcNation.id,
|
srcNationId: srcNation.id,
|
||||||
@@ -316,7 +314,6 @@ export const diplomacyRouter = router({
|
|||||||
state: 'PROPOSED',
|
state: 'PROPOSED',
|
||||||
textBrief: purifyDiplomacyHtml(input.brief),
|
textBrief: purifyDiplomacyHtml(input.brief),
|
||||||
textDetail: purifyDiplomacyHtml(input.detail),
|
textDetail: purifyDiplomacyHtml(input.detail),
|
||||||
date: letterDate,
|
|
||||||
srcSignerId: me.id,
|
srcSignerId: me.id,
|
||||||
aux: aux as GamePrisma.InputJsonValue,
|
aux: aux as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
@@ -332,7 +329,7 @@ export const diplomacyRouter = router({
|
|||||||
src: srcTarget,
|
src: srcTarget,
|
||||||
dest: destTarget,
|
dest: destTarget,
|
||||||
text,
|
text,
|
||||||
time: letterDate,
|
time: created.date,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id: created.id };
|
return { id: created.id };
|
||||||
@@ -371,7 +368,7 @@ export const diplomacyRouter = router({
|
|||||||
);
|
);
|
||||||
const messageSrc = buildActorTarget(me, destNation);
|
const messageSrc = buildActorTarget(me, destNation);
|
||||||
const messageDest = buildNationTarget(srcNation);
|
const messageDest = buildNationTarget(srcNation);
|
||||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||||
const aux = asRecord(letter.aux);
|
const aux = asRecord(letter.aux);
|
||||||
let messageText: string;
|
let messageText: string;
|
||||||
if (input.agree) {
|
if (input.agree) {
|
||||||
@@ -458,7 +455,7 @@ export const diplomacyRouter = router({
|
|||||||
);
|
);
|
||||||
const messageSrc = buildActorTarget(me, srcNation);
|
const messageSrc = buildActorTarget(me, srcNation);
|
||||||
const messageDest = buildNationTarget(destNation);
|
const messageDest = buildNationTarget(destNation);
|
||||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||||
const aux = asRecord(letter.aux);
|
const aux = asRecord(letter.aux);
|
||||||
aux.reason = {
|
aux.reason = {
|
||||||
who: me.id,
|
who: me.id,
|
||||||
@@ -519,7 +516,7 @@ export const diplomacyRouter = router({
|
|||||||
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
||||||
const messageSrc = buildActorTarget(me, actorNation);
|
const messageSrc = buildActorTarget(me, actorNation);
|
||||||
const messageDest = buildNationTarget(otherNation);
|
const messageDest = buildNationTarget(otherNation);
|
||||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||||
let resultState: 'ACTIVATED' | 'CANCELLED';
|
let resultState: 'ACTIVATED' | 'CANCELLED';
|
||||||
let messageText: string;
|
let messageText: string;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { z } from 'zod';
|
|||||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||||
|
import {
|
||||||
|
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||||
|
GamePrisma,
|
||||||
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
|
} from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
isWarTraitKey,
|
isWarTraitKey,
|
||||||
JOIN_PERSONALITY_TRAIT_KEYS,
|
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||||
@@ -393,15 +398,12 @@ export const joinRouter = router({
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
|
||||||
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
|
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'selectPoolReserve',
|
type: 'selectPoolReserve',
|
||||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||||
userId,
|
userId,
|
||||||
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
|
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
|
||||||
acceptedGameAt: gameTime.now.toISOString(),
|
|
||||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
|
||||||
});
|
});
|
||||||
return resolveSelectionReservationCommandResult(result);
|
return resolveSelectionReservationCommandResult(result);
|
||||||
}),
|
}),
|
||||||
@@ -431,7 +433,6 @@ export const joinRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'selectPoolCreate',
|
type: 'selectPoolCreate',
|
||||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||||
@@ -440,8 +441,6 @@ export const joinRouter = router({
|
|||||||
uniqueName: input.uniqueName,
|
uniqueName: input.uniqueName,
|
||||||
personality: input.personality,
|
personality: input.personality,
|
||||||
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||||
acceptedGameAt: gameTime.now.toISOString(),
|
|
||||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
|
||||||
...(selectedIcon
|
...(selectedIcon
|
||||||
? {
|
? {
|
||||||
ownerPicture: selectedIcon.picture,
|
ownerPicture: selectedIcon.picture,
|
||||||
@@ -471,15 +470,12 @@ export const joinRouter = router({
|
|||||||
input.clientRequestId,
|
input.clientRequestId,
|
||||||
'reselect'
|
'reselect'
|
||||||
);
|
);
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'selectPoolReselect',
|
type: 'selectPoolReselect',
|
||||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||||
userId,
|
userId,
|
||||||
ownerDisplayName: auth.user.displayName,
|
ownerDisplayName: auth.user.displayName,
|
||||||
uniqueName: input.uniqueName,
|
uniqueName: input.uniqueName,
|
||||||
acceptedGameAt: gameTime.now.toISOString(),
|
|
||||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
|
||||||
});
|
});
|
||||||
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
||||||
}),
|
}),
|
||||||
@@ -583,30 +579,46 @@ export const joinRouter = router({
|
|||||||
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const worldState = await ctx.db.worldState.findFirst();
|
try {
|
||||||
if (!worldState) {
|
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({
|
throw new TRPCError({
|
||||||
code: 'PRECONDITION_FAILED',
|
code: 'PRECONDITION_FAILED',
|
||||||
message: 'World state is not initialized.',
|
message: 'World state is not initialized.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) {
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
throw new TRPCError({
|
||||||
if (gameTime.tick === null) {
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const worldState = await transaction.worldState.findFirst();
|
||||||
|
const gameTime = await loadCurrentGameTime(transaction);
|
||||||
|
if (!worldState || gameTime.tick === null) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'PRECONDITION_FAILED',
|
code: 'PRECONDITION_FAILED',
|
||||||
message: 'Game clock is not initialized.',
|
message: 'Game clock is not initialized.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return await reserveNpcPossessionCandidates({
|
return reserveNpcPossessionCandidates({
|
||||||
db: ctx.db,
|
db: transaction,
|
||||||
worldState,
|
worldState,
|
||||||
userId: auth.user.id,
|
userId: auth.user.id,
|
||||||
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
|
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
|
||||||
refresh: input.refresh,
|
refresh: input.refresh,
|
||||||
keepIds: input.keepIds,
|
keepIds: input.keepIds,
|
||||||
now: gameTime.now,
|
now: gameTime.now,
|
||||||
acceptedGameTick: gameTime.tick,
|
createdGameTick: gameTime.tick,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NpcPossessionError) {
|
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 { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||||
|
|
||||||
import type { GameApiContext } from '../../context.js';
|
import type { GameApiContext } from '../../context.js';
|
||||||
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
import {
|
||||||
|
accessLimitAuthedInputProcedure,
|
||||||
|
accessWallAuthedInputProcedure,
|
||||||
|
authedProcedure,
|
||||||
|
router,
|
||||||
|
wallAuthedProcedure,
|
||||||
|
} from '../../trpc.js';
|
||||||
import {
|
import {
|
||||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||||
MESSAGE_MAILBOX_PUBLIC,
|
MESSAGE_MAILBOX_PUBLIC,
|
||||||
@@ -20,13 +26,12 @@ import {
|
|||||||
fetchOldMessagesFromMailbox,
|
fetchOldMessagesFromMailbox,
|
||||||
fetchMessageById,
|
fetchMessageById,
|
||||||
insertMessage,
|
insertMessage,
|
||||||
tombstoneMessages,
|
tombstoneMessagesWithinDeleteWindow,
|
||||||
type MessageView,
|
type MessageView,
|
||||||
} from '../../messages/store.js';
|
} from '../../messages/store.js';
|
||||||
import { getOwnedGeneral } from '../shared/general.js';
|
import { getOwnedGeneral } from '../shared/general.js';
|
||||||
import { resolveNationPermission } from '../nation/shared.js';
|
import { resolveNationPermission } from '../nation/shared.js';
|
||||||
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
||||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
|
||||||
|
|
||||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||||
|
|
||||||
@@ -231,7 +236,7 @@ export const messagesRouter = router({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
readLatest: authedProcedure
|
readLatest: wallAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
@@ -264,7 +269,7 @@ export const messagesRouter = router({
|
|||||||
`;
|
`;
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
delete: authedProcedure
|
delete: wallAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
@@ -289,17 +294,16 @@ export const messagesRouter = router({
|
|||||||
if (message.payload.option?.deletable === false) {
|
if (message.payload.option?.deletable === false) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
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 receiverMessageId = message.payload.option?.receiverMessageID;
|
||||||
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
||||||
const ids = [
|
const ids = [
|
||||||
message.id,
|
message.id,
|
||||||
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
...(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 =
|
const receiverMailbox =
|
||||||
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
||||||
? message.payload.dest.generalId
|
? message.payload.dest.generalId
|
||||||
@@ -309,7 +313,7 @@ export const messagesRouter = router({
|
|||||||
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
||||||
: null;
|
: null;
|
||||||
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
||||||
return { ok: true, deletedIds: ids };
|
return { ok: true, deletedIds };
|
||||||
}),
|
}),
|
||||||
respond: authedProcedure
|
respond: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -420,7 +424,7 @@ export const messagesRouter = router({
|
|||||||
...messageBuckets,
|
...messageBuckets,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
send: accessAuthedInputProcedure(
|
send: accessWallAuthedInputProcedure(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
mailbox: z.number().int(),
|
mailbox: z.number().int(),
|
||||||
@@ -436,7 +440,9 @@ export const messagesRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
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');
|
const validUntil = new Date('9999-12-31T00:00:00Z');
|
||||||
|
|
||||||
let msgType: MessageType;
|
let msgType: MessageType;
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import type { TournamentState } from '../../tournament/types.js';
|
|||||||
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
|
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
|
||||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||||
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.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 { getMyGeneral } from '../shared/general.js';
|
||||||
import { loadCurrentGameTime } from '../../services/gameClock.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';
|
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||||
|
|
||||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||||
@@ -64,7 +64,7 @@ const withTournamentClockMutation = async <T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const clockContext: TournamentClockContext = {
|
const clockContext: TournamentClockContext = {
|
||||||
phase: 'RUNNING',
|
phase: fence.phase,
|
||||||
revision: fence.revision,
|
revision: fence.revision,
|
||||||
deadlineGeneration: fence.generation,
|
deadlineGeneration: fence.generation,
|
||||||
dateToTick: gameTime.dateToTick,
|
dateToTick: gameTime.dateToTick,
|
||||||
@@ -72,6 +72,37 @@ const withTournamentClockMutation = async <T>(
|
|||||||
return store.withClockContext(clockContext, () => store.withMutationLock(operation));
|
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({
|
const zTournamentState = z.object({
|
||||||
stage: z.number().int().min(0),
|
stage: z.number().int().min(0),
|
||||||
phase: z.number().int().min(0),
|
phase: z.number().int().min(0),
|
||||||
@@ -544,7 +575,10 @@ export const tournamentRouter = router({
|
|||||||
return { ok: true };
|
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(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
targetId: z.number().int().positive(),
|
targetId: z.number().int().positive(),
|
||||||
@@ -554,7 +588,7 @@ export const tournamentRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
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();
|
const state = await store.getState();
|
||||||
if (!state || state.stage !== 6) {
|
if (!state || state.stage !== 6) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
||||||
@@ -589,6 +623,7 @@ export const tournamentRouter = router({
|
|||||||
|
|
||||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
|
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||||
reason: 'tournamentBet',
|
reason: 'tournamentBet',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||||
});
|
});
|
||||||
@@ -604,6 +639,7 @@ export const tournamentRouter = router({
|
|||||||
|
|
||||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralMeta',
|
type: 'adjustGeneralMeta',
|
||||||
|
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
||||||
reason: 'tournamentBet',
|
reason: 'tournamentBet',
|
||||||
adjustments: [
|
adjustments: [
|
||||||
{
|
{
|
||||||
@@ -615,6 +651,7 @@ export const tournamentRouter = router({
|
|||||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||||
await ctx.turnDaemon.requestCommand({
|
await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
|
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
||||||
reason: 'tournamentBetRollback',
|
reason: 'tournamentBetRollback',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||||
});
|
});
|
||||||
@@ -631,11 +668,13 @@ export const tournamentRouter = router({
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
ctx.turnDaemon.requestCommand({
|
ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
|
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||||
reason: 'tournamentBetRollback',
|
reason: 'tournamentBetRollback',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||||
}),
|
}),
|
||||||
ctx.turnDaemon.requestCommand({
|
ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralMeta',
|
type: 'adjustGeneralMeta',
|
||||||
|
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
||||||
reason: 'tournamentBetRollback',
|
reason: 'tournamentBetRollback',
|
||||||
adjustments: [
|
adjustments: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -102,18 +102,16 @@ export const hasPollEnded = (
|
|||||||
time: CurrentGameTime
|
time: CurrentGameTime
|
||||||
): boolean =>
|
): boolean =>
|
||||||
Boolean(poll.closed_at) ||
|
Boolean(poll.closed_at) ||
|
||||||
(poll.end_tick !== null && time.tick !== null
|
Boolean(
|
||||||
? poll.end_tick < BigInt(time.tick)
|
poll.end_at &&
|
||||||
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
|
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
|
||||||
|
);
|
||||||
|
|
||||||
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
|
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
try {
|
|
||||||
const tick = time.dateToTick(date);
|
const tick = time.dateToTick(date);
|
||||||
return tick === null ? null : BigInt(tick);
|
if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
|
||||||
} catch {
|
return BigInt(tick);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type VoteListRow = {
|
type VoteListRow = {
|
||||||
@@ -358,7 +356,6 @@ export const voteRouter = router({
|
|||||||
voteId: input.voteId,
|
voteId: input.voteId,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
selection: sortedSelection,
|
selection: sortedSelection,
|
||||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
|
||||||
});
|
});
|
||||||
throwIfCommandRejected(rewardResult);
|
throwIfCommandRejected(rewardResult);
|
||||||
|
|
||||||
@@ -399,8 +396,6 @@ export const voteRouter = router({
|
|||||||
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
|
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
|
||||||
: null;
|
: null;
|
||||||
const nationName = nation?.name ?? '재야';
|
const nationName = nation?.name ?? '재야';
|
||||||
const createdAt = new Date();
|
|
||||||
|
|
||||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||||
INSERT INTO vote_comment (
|
INSERT INTO vote_comment (
|
||||||
vote_id,
|
vote_id,
|
||||||
@@ -418,7 +413,7 @@ export const voteRouter = router({
|
|||||||
${general.name},
|
${general.name},
|
||||||
${nationName},
|
${nationName},
|
||||||
${input.text},
|
${input.text},
|
||||||
${createdAt}
|
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -451,7 +446,6 @@ export const voteRouter = router({
|
|||||||
if (endAt && endAt < gameTime.now) {
|
if (endAt && endAt < gameTime.now) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||||
}
|
}
|
||||||
const operationalAt = new Date();
|
|
||||||
|
|
||||||
let multipleOptions = input.multipleOptions;
|
let multipleOptions = input.multipleOptions;
|
||||||
if (multipleOptions < 0) {
|
if (multipleOptions < 0) {
|
||||||
@@ -464,7 +458,8 @@ export const voteRouter = router({
|
|||||||
if (input.closePrevious) {
|
if (input.closePrevious) {
|
||||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||||
UPDATE vote_poll
|
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
|
WHERE closed_at IS NULL
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -497,8 +492,8 @@ export const voteRouter = router({
|
|||||||
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
||||||
${endAt},
|
${endAt},
|
||||||
${toGameTickOrNull(gameTime, endAt)},
|
${toGameTickOrNull(gameTime, endAt)},
|
||||||
${operationalAt},
|
CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
${operationalAt}
|
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -573,7 +568,6 @@ export const voteRouter = router({
|
|||||||
if (endAt && endAt < gameTime.now) {
|
if (endAt && endAt < gameTime.now) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||||
}
|
}
|
||||||
const updatedAt = new Date();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
input.title === undefined &&
|
input.title === undefined &&
|
||||||
@@ -596,7 +590,7 @@ export const voteRouter = router({
|
|||||||
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
|
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
|
||||||
end_at = ${endAt ?? poll.end_at},
|
end_at = ${endAt ?? poll.end_at},
|
||||||
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
|
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
|
||||||
updated_at = ${updatedAt}
|
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
WHERE id = ${input.voteId}
|
WHERE id = ${input.voteId}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -609,10 +603,10 @@ export const voteRouter = router({
|
|||||||
.input(z.object({ voteId: z.number().int().positive() }))
|
.input(z.object({ voteId: z.number().int().positive() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
const updatedAt = new Date();
|
|
||||||
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||||
UPDATE vote_poll
|
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}
|
WHERE id = ${input.voteId}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CurrentGameTime } from './gameClock.js';
|
import type { CurrentGameTime } from './gameClock.js';
|
||||||
|
import type { GameClockPhase } from '@sammo-ts/common';
|
||||||
|
|
||||||
interface ClockFenceRedis {
|
interface ClockFenceRedis {
|
||||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||||
@@ -26,30 +27,58 @@ export interface ActiveRedisClockFence {
|
|||||||
phaseKey: string;
|
phaseKey: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
generation: number;
|
generation: number;
|
||||||
|
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ensureActiveRedisClockFence = async (
|
type MutableProjectionPhase = ActiveRedisClockFence['phase'];
|
||||||
|
|
||||||
|
const ensureRedisClockFence = async (
|
||||||
redis: ClockFenceRedis,
|
redis: ClockFenceRedis,
|
||||||
profileName: string,
|
profileName: string,
|
||||||
gameTime: CurrentGameTime
|
gameTime: CurrentGameTime,
|
||||||
|
allowedPhases: readonly GameClockPhase[]
|
||||||
): Promise<ActiveRedisClockFence | null> => {
|
): Promise<ActiveRedisClockFence | null> => {
|
||||||
if (
|
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.revision) ||
|
||||||
!Number.isSafeInteger(gameTime.deadlineGeneration)
|
!Number.isSafeInteger(gameTime.deadlineGeneration)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const phase: MutableProjectionPhase = gameTime.phase;
|
||||||
const fence: ActiveRedisClockFence = {
|
const fence: ActiveRedisClockFence = {
|
||||||
activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
|
activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
|
||||||
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
|
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
|
||||||
phaseKey: `sammo:${profileName}:clock:phase`,
|
phaseKey: `sammo:${profileName}:clock:phase`,
|
||||||
revision: gameTime.revision!,
|
revision: gameTime.revision!,
|
||||||
generation: gameTime.deadlineGeneration!,
|
generation: gameTime.deadlineGeneration!,
|
||||||
|
phase,
|
||||||
};
|
};
|
||||||
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
|
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
|
||||||
keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey],
|
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;
|
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 { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
|
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
|
||||||
|
|
||||||
interface TurnDaemonLeaseSource {
|
interface TurnDaemonLeaseSource {
|
||||||
turnDaemonLease: {
|
$queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
|
||||||
findUnique(input: {
|
|
||||||
where: { profile: string };
|
|
||||||
select: { leaseUntil: true };
|
|
||||||
}): Promise<{ leaseUntil: Date } | null>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadTurnEngineRunning = async (
|
export const loadTurnEngineRunning = async (
|
||||||
source: ProfileStatusSource | undefined,
|
source: ProfileStatusSource | undefined,
|
||||||
db: TurnDaemonLeaseSource,
|
db: TurnDaemonLeaseSource,
|
||||||
profileName: string,
|
profileName: string,
|
||||||
now = new Date()
|
now?: Date
|
||||||
): Promise<boolean | null> => {
|
): Promise<boolean | null> => {
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
try {
|
try {
|
||||||
const status = await source.get(profileName);
|
const status = await source.get(profileName);
|
||||||
if (status === null) return null;
|
if (status === null) return null;
|
||||||
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
|
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
|
||||||
const lease = await db.turnDaemonLease.findUnique({
|
const wallNow = now
|
||||||
where: { profile: profileName },
|
? GamePrisma.sql`${now}`
|
||||||
select: { leaseUntil: true },
|
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
|
||||||
});
|
const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
|
||||||
return lease !== null && lease.leaseUntil.getTime() > now.getTime();
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM turn_daemon_lease
|
||||||
|
WHERE profile = ${profileName}
|
||||||
|
AND lease_until > ${wallNow}
|
||||||
|
) AS running
|
||||||
|
`);
|
||||||
|
return rows[0]?.running ?? false;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
|
|||||||
private readonly db: TurnDaemonLeaseSource,
|
private readonly db: TurnDaemonLeaseSource,
|
||||||
private readonly profileName: string,
|
private readonly profileName: string,
|
||||||
private readonly cacheMs = 2_000,
|
private readonly cacheMs = 2_000,
|
||||||
private readonly now = () => Date.now()
|
private readonly now = () => performance.now()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get(): Promise<boolean | null> {
|
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 { createHmac, randomUUID } from 'node:crypto';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
||||||
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
|
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
|
||||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
|
|||||||
`);
|
`);
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
const ids = rows.map((row) => row.id);
|
const ids = rows.map((row) => row.id);
|
||||||
await tx.webPushOutbox.updateMany({
|
await tx.$executeRaw(GamePrisma.sql`
|
||||||
where: { id: { in: ids } },
|
UPDATE "web_push_outbox"
|
||||||
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
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({
|
return tx.webPushOutbox.findMany({
|
||||||
where: { id: { in: ids }, lockOwner: this.owner },
|
where: { id: { in: ids }, lockOwner: this.owner },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const event of claimed) {
|
for (const event of claimed) {
|
||||||
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
|
const expired = await this.db.$executeRaw(GamePrisma.sql`
|
||||||
await this.db.webPushOutbox.updateMany({
|
UPDATE "web_push_outbox"
|
||||||
where: { id: event.id, lockOwner: this.owner },
|
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
"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;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
|
|||||||
signal: AbortSignal.timeout(5_000),
|
signal: AbortSignal.timeout(5_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
|
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
|
||||||
await this.db.webPushOutbox.updateMany({
|
await this.db.$executeRaw(GamePrisma.sql`
|
||||||
where: { id: event.id, lockOwner: this.owner },
|
UPDATE "web_push_outbox"
|
||||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
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) {
|
} catch (error) {
|
||||||
const attempts = event.attempts;
|
const attempts = event.attempts;
|
||||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||||
await this.db.webPushOutbox.updateMany({
|
const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
||||||
where: { id: event.id, lockOwner: this.owner },
|
await this.db.$executeRaw(GamePrisma.sql`
|
||||||
data: {
|
UPDATE "web_push_outbox"
|
||||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||||
lockedAt: null,
|
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
||||||
lockOwner: null,
|
"locked_at" = NULL,
|
||||||
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
|
"lock_owner" = NULL,
|
||||||
},
|
"last_error" = ${errorText}
|
||||||
});
|
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
|
||||||
|
`);
|
||||||
this.onError(error);
|
this.onError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Date.now() >= this.nextPruneAt) {
|
if (performance.now() >= this.nextPruneAt) {
|
||||||
this.nextPruneAt = Date.now() + 60_000;
|
this.nextPruneAt = performance.now() + 60_000;
|
||||||
await this.db.$executeRaw(GamePrisma.sql`
|
await this.db.$executeRaw(GamePrisma.sql`
|
||||||
WITH expired AS (
|
WITH expired AS (
|
||||||
SELECT "id"
|
SELECT "id"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
|
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface TournamentClockContext {
|
export interface TournamentClockContext {
|
||||||
phase: 'RUNNING';
|
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
||||||
revision: number;
|
revision: number;
|
||||||
deadlineGeneration: number;
|
deadlineGeneration: number;
|
||||||
dateToTick(date: Date): number | null;
|
dateToTick(date: Date): number | null;
|
||||||
@@ -198,8 +199,8 @@ export class TournamentStore {
|
|||||||
|
|
||||||
const lockKey = `${this.keys.stateKey}:mutation-lock`;
|
const lockKey = `${this.keys.stateKey}:mutation-lock`;
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = performance.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (performance.now() < deadline) {
|
||||||
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||||
if (acquired) {
|
if (acquired) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => {
|
|||||||
|
|
||||||
const resolveScheduledBaseMs = (state: TournamentState): number => {
|
const resolveScheduledBaseMs = (state: TournamentState): number => {
|
||||||
const scheduled = new Date(state.nextAt).getTime();
|
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 =>
|
export const resolveNextAt = (state: TournamentState): string =>
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
|
|||||||
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
|
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
|
||||||
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
|
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
|
||||||
|
|
||||||
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
const createInputEventMiddleware = (acquireClockFence: boolean) =>
|
||||||
|
t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
||||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
@@ -79,6 +80,7 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex,
|
|||||||
eventType: path,
|
eventType: path,
|
||||||
payload,
|
payload,
|
||||||
actorUserId: ctx.auth?.user.id,
|
actorUserId: ctx.auth?.user.id,
|
||||||
|
acquireClockFence,
|
||||||
execute: async (transaction) => {
|
execute: async (transaction) => {
|
||||||
const result = await next({
|
const result = await next({
|
||||||
ctx: {
|
ctx: {
|
||||||
@@ -91,7 +93,9 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex,
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
|
journalPersisted = Boolean(
|
||||||
|
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
||||||
|
);
|
||||||
executedResult = result;
|
executedResult = result;
|
||||||
return result.data;
|
return result.data;
|
||||||
},
|
},
|
||||||
@@ -116,7 +120,10 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex,
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inputEventMiddleware = createInputEventMiddleware(true);
|
||||||
|
const wallInputEventMiddleware = createInputEventMiddleware(false);
|
||||||
|
|
||||||
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
|
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
|
||||||
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
|
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
|
||||||
@@ -180,10 +187,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
|
|||||||
|
|
||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||||
|
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
|
||||||
export const authedProcedure: typeof procedure = t.procedure
|
export const authedProcedure: typeof procedure = t.procedure
|
||||||
.use(requireAuthMiddleware)
|
.use(requireAuthMiddleware)
|
||||||
.use(generalActivityMiddleware)
|
.use(generalActivityMiddleware)
|
||||||
.use(inputEventMiddleware);
|
.use(inputEventMiddleware);
|
||||||
|
export const wallAuthedProcedure: typeof procedure = t.procedure
|
||||||
|
.use(requireAuthMiddleware)
|
||||||
|
.use(generalActivityMiddleware)
|
||||||
|
.use(wallInputEventMiddleware);
|
||||||
|
|
||||||
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
||||||
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
||||||
@@ -234,6 +246,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
|
|||||||
.use(generalAccessEndpointMiddleware)
|
.use(generalAccessEndpointMiddleware)
|
||||||
.use(generalActivityMiddleware)
|
.use(generalActivityMiddleware)
|
||||||
.use(inputEventMiddleware);
|
.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) =>
|
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||||
t.procedure
|
t.procedure
|
||||||
.use(requireAuthMiddleware)
|
.use(requireAuthMiddleware)
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const buildContext = (options: {
|
|||||||
ok: true as const,
|
ok: true as const,
|
||||||
auctionId: 91,
|
auctionId: 91,
|
||||||
closeAt: '2026-07-27T00:00:00.000Z',
|
closeAt: '2026-07-27T00:00:00.000Z',
|
||||||
|
closeTick: 200,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -103,6 +104,7 @@ const buildContext = (options: {
|
|||||||
ok: true as const,
|
ok: true as const,
|
||||||
auctionId: 91,
|
auctionId: 91,
|
||||||
closeAt: '2026-07-27T00:00:00.000Z',
|
closeAt: '2026-07-27T00:00:00.000Z',
|
||||||
|
closeTick: 200,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
||||||
@@ -112,14 +114,10 @@ const buildContext = (options: {
|
|||||||
currentYear: 200,
|
currentYear: 200,
|
||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 3600,
|
tickSeconds: 3600,
|
||||||
...(options.clockTick === undefined
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
clockTick: BigInt(options.clockTick),
|
clockTick: BigInt(options.clockTick ?? 100),
|
||||||
clockMode: 'manual',
|
clockMode: 'manual',
|
||||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
}),
|
|
||||||
config: {
|
config: {
|
||||||
const: {
|
const: {
|
||||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||||
@@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => {
|
|||||||
tick: 72_000_001,
|
tick: 72_000_001,
|
||||||
})
|
})
|
||||||
).toBe(true);
|
).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 () => {
|
it('rejects unauthenticated auction reads', async () => {
|
||||||
@@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => {
|
|||||||
auctionId: 31,
|
auctionId: 31,
|
||||||
generalId: 7,
|
generalId: 7,
|
||||||
amount: 110,
|
amount: 110,
|
||||||
acceptedGameTick: 100,
|
|
||||||
tryExtendCloseDate: false,
|
tryExtendCloseDate: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => {
|
|||||||
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
|
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt: new Date(Date.now() + 60 * 60_000),
|
closeAt: new Date(Date.now() + 60 * 60_000),
|
||||||
|
closeTick: 200n,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => {
|
|||||||
auctionId: 31,
|
auctionId: 31,
|
||||||
generalId: 7,
|
generalId: 7,
|
||||||
amount: 500,
|
amount: 500,
|
||||||
acceptedGameTick: 100,
|
|
||||||
tryExtendCloseDate: true,
|
tryExtendCloseDate: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
detail: { amount: 100 },
|
detail: { amount: 100 },
|
||||||
status,
|
status,
|
||||||
closeAt,
|
closeAt,
|
||||||
|
closeTick: 0n,
|
||||||
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
|
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
return auction;
|
return auction;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
|
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
|
||||||
buildAuctionFinalizeRequestId(auction.id, {
|
if (auction.closeTick === null || auction.closeTick === undefined) {
|
||||||
|
throw new Error(`auction ${auction.id} fixture requires closeTick`);
|
||||||
|
}
|
||||||
|
return buildAuctionFinalizeRequestId(auction.id, {
|
||||||
closeAt: auction.closeAt,
|
closeAt: auction.closeAt,
|
||||||
closeTick: auction.closeTick ?? null,
|
closeTick: auction.closeTick,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const memoryRedis = () => ({
|
const memoryRedis = () => ({
|
||||||
zRangeByScore: vi.fn(async () => []),
|
zRangeByScore: vi.fn(async () => []),
|
||||||
@@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
|
nowTick: 0,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
await expect(
|
await expect(
|
||||||
@@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
|
nowTick: 0,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
|
nowTick: 0,
|
||||||
})
|
})
|
||||||
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
|
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
|
||||||
|
|
||||||
@@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
eventType: 'auctionFinalize',
|
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',
|
status: 'FAILED',
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
error: 'simulated terminal failure',
|
error: 'simulated terminal failure',
|
||||||
@@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
|
nowTick: 0,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
await expect(
|
await expect(
|
||||||
@@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
|
nowTick: 0,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
await expect(
|
await expect(
|
||||||
@@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
|
nowTick: 0,
|
||||||
})
|
})
|
||||||
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
|
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a new generation after an earlier close was extended', async () => {
|
it('creates a new generation after an earlier close was extended', async () => {
|
||||||
const auction = await createAuction('OPEN');
|
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({
|
await connector.prisma.inputEvent.create({
|
||||||
data: {
|
data: {
|
||||||
requestId: priorRequestId,
|
requestId: priorRequestId,
|
||||||
@@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
|
nowTick: 0,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -431,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
amount: 200,
|
amount: 200,
|
||||||
eventId: `auction-durable-bid:${auction.id}`,
|
eventId: `auction-durable-bid:${auction.id}`,
|
||||||
eventAt: new Date(),
|
eventAt: new Date(),
|
||||||
|
occurredGameTick: 0n,
|
||||||
|
requestedAtWall: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const requestId = requestIdFor(auction);
|
const requestId = requestIdFor(auction);
|
||||||
@@ -441,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
|
nowTick: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||||
@@ -509,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
detail: { remainCloseDateExtensionCnt: 1 },
|
detail: { remainCloseDateExtensionCnt: 1 },
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt: logicalPastCloseAt,
|
closeAt: logicalPastCloseAt,
|
||||||
|
closeTick: 0n,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
extensionAuctionId = extensionAuction.id;
|
extensionAuctionId = extensionAuction.id;
|
||||||
@@ -521,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
amount: 50,
|
amount: 50,
|
||||||
eventId: `auction-extension-bid:${extensionAuction.id}`,
|
eventId: `auction-extension-bid:${extensionAuction.id}`,
|
||||||
eventAt: new Date(),
|
eventAt: new Date(),
|
||||||
|
occurredGameTick: 0n,
|
||||||
|
requestedAtWall: new Date(),
|
||||||
meta: { tryExtendCloseDate: true },
|
meta: { tryExtendCloseDate: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -532,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(extensionAuction.id),
|
id: String(extensionAuction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
|
nowTick: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
let reopened: { status: string; closeAt: Date } | null = null;
|
let reopened: { status: string; closeAt: Date } | null = null;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const buildDb = (options: {
|
|||||||
$executeRaw: vi.fn(async () => options.updated),
|
$executeRaw: vi.fn(async () => options.updated),
|
||||||
auction: {
|
auction: {
|
||||||
findUnique: vi.fn(async () =>
|
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: {
|
inputEvent: {
|
||||||
@@ -233,11 +233,12 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
|
nowTick: 36_000_000,
|
||||||
})
|
})
|
||||||
).resolves.toBe('RESCHEDULED');
|
).resolves.toBe('RESCHEDULED');
|
||||||
|
|
||||||
expect(redis.zAdd).toHaveBeenCalledTimes(1);
|
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();
|
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 () => {
|
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
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 { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||||
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||||
|
|
||||||
@@ -279,6 +280,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs,
|
nowMs,
|
||||||
|
nowTick: 72_000_000,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -293,6 +295,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
auctionId: 7,
|
auctionId: 7,
|
||||||
expectedCloseAt: closeAt.toISOString(),
|
expectedCloseAt: closeAt.toISOString(),
|
||||||
|
expectedCloseTick: 72_000_000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -324,7 +327,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
eventType: 'auctionFinalize',
|
eventType: 'auctionFinalize',
|
||||||
acceptedGameTick: 72_000_000n,
|
|
||||||
payload: {
|
payload: {
|
||||||
type: 'auctionFinalize',
|
type: 'auctionFinalize',
|
||||||
requestId,
|
requestId,
|
||||||
@@ -351,6 +353,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
|
nowTick: 72_000_000,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).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 () => {
|
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
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({
|
const { db, transaction } = buildDb({
|
||||||
updated: 0,
|
updated: 0,
|
||||||
auction: { status: 'OPEN', closeAt },
|
auction: { status: 'OPEN', closeAt },
|
||||||
@@ -377,6 +380,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
auctionId: 7,
|
auctionId: 7,
|
||||||
expectedCloseAt: closeAt.toISOString(),
|
expectedCloseAt: closeAt.toISOString(),
|
||||||
|
expectedCloseTick: 72_000_000,
|
||||||
},
|
},
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
result: null,
|
result: null,
|
||||||
@@ -392,6 +396,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
|
nowTick: 72_000_000,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||||
@@ -412,6 +417,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: logicalNowMs,
|
nowMs: logicalNowMs,
|
||||||
|
nowTick: 72_000_000,
|
||||||
historyNowMs: operationalNowMs,
|
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 () => {
|
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
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 = {
|
const existingEvent = {
|
||||||
requestId,
|
requestId,
|
||||||
target: 'ENGINE' as const,
|
target: 'ENGINE' as const,
|
||||||
eventType: 'auctionFinalize',
|
eventType: 'auctionFinalize',
|
||||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
||||||
status: 'PENDING' as const,
|
status: 'PENDING' as const,
|
||||||
result: null,
|
result: null,
|
||||||
};
|
};
|
||||||
@@ -444,6 +450,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
|
nowTick: 72_000_000,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -456,7 +463,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
it('creates one bounded successor after a terminal event failure', async () => {
|
it('creates one bounded successor after a terminal event failure', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
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 retryRequestId = `${requestId}:retry:1`;
|
||||||
const { db, transaction } = buildDb({
|
const { db, transaction } = buildDb({
|
||||||
updated: 0,
|
updated: 0,
|
||||||
@@ -466,7 +473,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
eventType: 'auctionFinalize',
|
eventType: 'auctionFinalize',
|
||||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
||||||
status: 'FAILED',
|
status: 'FAILED',
|
||||||
result: null,
|
result: null,
|
||||||
},
|
},
|
||||||
@@ -481,6 +488,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
|
nowTick: 72_000_000,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -494,6 +502,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId: retryRequestId,
|
requestId: retryRequestId,
|
||||||
auctionId: 7,
|
auctionId: 7,
|
||||||
expectedCloseAt: closeAt.toISOString(),
|
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 () => {
|
it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
|
|
||||||
const closeAt = new Date('2026-07-30T11:30:00.000Z');
|
const closeAt = new Date('2026-07-30T11:30:00.000Z');
|
||||||
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
|
const previousRequestId = 'auction:finalize:7:tick:36000000';
|
||||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
const requestId = 'auction:finalize:7:tick:72000000';
|
||||||
const { db, transaction } = buildDb({
|
const { db, transaction } = buildDb({
|
||||||
updated: 0,
|
updated: 0,
|
||||||
auction: { status: 'OPEN', closeAt },
|
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
|
||||||
existingEvents: [
|
existingEvents: [
|
||||||
{
|
{
|
||||||
requestId: previousRequestId,
|
requestId: previousRequestId,
|
||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
eventType: 'auctionFinalize',
|
eventType: 'auctionFinalize',
|
||||||
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 },
|
payload: {
|
||||||
|
type: 'auctionFinalize',
|
||||||
|
requestId: previousRequestId,
|
||||||
|
auctionId: 7,
|
||||||
|
expectedCloseTick: 36_000_000,
|
||||||
|
},
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
|
result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
|
||||||
},
|
},
|
||||||
@@ -528,6 +541,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
|
nowTick: 72_000_000,
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -541,6 +555,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
auctionId: 7,
|
auctionId: 7,
|
||||||
expectedCloseAt: closeAt.toISOString(),
|
expectedCloseAt: closeAt.toISOString(),
|
||||||
|
expectedCloseTick: 72_000_000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -560,6 +575,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
|
nowTick: 72_000_000,
|
||||||
})
|
})
|
||||||
).rejects.toThrow('event insert failed');
|
).rejects.toThrow('event insert failed');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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 { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ const storedLetter = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = 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;
|
let messageId = 100;
|
||||||
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
|
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
|
||||||
const db = {
|
const db = {
|
||||||
@@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => {
|
|||||||
textBrief: '<p><strong>공개</strong></p>',
|
textBrief: '<p><strong>공개</strong></p>',
|
||||||
textDetail:
|
textDetail:
|
||||||
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
|
'<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).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 () => {
|
it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
|
||||||
|
|||||||
@@ -326,18 +326,14 @@ describe('general access tracking', () => {
|
|||||||
: [{ id: 41 }];
|
: [{ id: 41 }];
|
||||||
}),
|
}),
|
||||||
$executeRaw: vi.fn(async (query: unknown) => {
|
$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');
|
events.push('input-event-create');
|
||||||
}
|
}
|
||||||
|
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
|
||||||
return 1;
|
return 1;
|
||||||
}),
|
}),
|
||||||
$executeRawUnsafe: vi.fn(async () => 0),
|
$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 = {
|
const db = {
|
||||||
general: {
|
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 = {
|
const persistedPayload = {
|
||||||
type: 'voteReward' as const,
|
type: 'voteReward' as const,
|
||||||
requestId: 'vote-reward',
|
requestId: 'vote-reward',
|
||||||
@@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => {
|
|||||||
selection: [0],
|
selection: [0],
|
||||||
acceptedGameTick: 100,
|
acceptedGameTick: 100,
|
||||||
};
|
};
|
||||||
|
const currentCommand = {
|
||||||
|
type: 'voteReward' as const,
|
||||||
|
requestId: 'vote-reward',
|
||||||
|
userId: 'user-7',
|
||||||
|
voteId: 1,
|
||||||
|
generalId: 7,
|
||||||
|
selection: [0],
|
||||||
|
};
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
|
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
|
||||||
};
|
};
|
||||||
@@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
transport.sendCommand({
|
transport.sendCommand(currentCommand)
|
||||||
...persistedPayload,
|
|
||||||
acceptedGameTick: 101,
|
|
||||||
})
|
|
||||||
).resolves.toBe('vote-reward');
|
).resolves.toBe('vote-reward');
|
||||||
|
|
||||||
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
|
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
|
||||||
await expect(
|
await expect(
|
||||||
transport.sendCommand({
|
transport.sendCommand({
|
||||||
...persistedPayload,
|
...currentCommand,
|
||||||
...changedIdentity,
|
...changedIdentity,
|
||||||
acceptedGameTick: 101,
|
|
||||||
})
|
})
|
||||||
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
|
).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 () => {
|
it('reuses the same engine child event but rejects a changed retry payload', async () => {
|
||||||
const transport = new DatabaseTurnDaemonTransport(db, 100);
|
const transport = new DatabaseTurnDaemonTransport(db, 100);
|
||||||
const requestId = 'integration:api:engine-child';
|
const requestId = 'integration:api:engine-child';
|
||||||
const worldClock = await db.worldState.findFirst({
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { clockRevision: true, deadlineGeneration: true },
|
|
||||||
});
|
|
||||||
const acceptedWindowStart = Date.now();
|
const acceptedWindowStart = Date.now();
|
||||||
await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 });
|
await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 });
|
||||||
const acceptedWindowEnd = Date.now();
|
const acceptedWindowEnd = Date.now();
|
||||||
@@ -539,9 +535,10 @@ integration('API input event boundary', () => {
|
|||||||
expect(event.actorUserId).toBe('user-7');
|
expect(event.actorUserId).toBe('user-7');
|
||||||
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
|
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
|
||||||
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
|
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
|
||||||
expect(event.acceptedGameTick).not.toBeNull();
|
expect(event.acceptedGameTick).toBeNull();
|
||||||
expect(event.acceptedClockRevision).toBe(worldClock?.clockRevision ?? null);
|
expect(event.acceptedClockRevision).toBeNull();
|
||||||
expect(event.acceptedDeadlineGeneration).toBe(worldClock?.deadlineGeneration ?? null);
|
expect(event.acceptedDeadlineGeneration).toBeNull();
|
||||||
|
expect(event.processingGameTick).toBeNull();
|
||||||
await expect(
|
await expect(
|
||||||
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
|
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
|
||||||
).resolves.toBe(requestId);
|
).resolves.toBe(requestId);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import type { GameApiContext } from '../src/context.js';
|
import type { GameApiContext } from '../src/context.js';
|
||||||
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.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({
|
const testRouter = router({
|
||||||
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
|
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 createContext = (payload: unknown = {}) => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const queryRaw = vi.fn(async (query: { sql?: string }) => {
|
const queryRaw = vi.fn(async (query: { sql?: string }) => {
|
||||||
@@ -40,7 +48,18 @@ const createContext = (payload: unknown = {}) => {
|
|||||||
const transaction = {
|
const transaction = {
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
$executeRaw: vi.fn(async (query: { sql?: string }) => {
|
$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;
|
return 1;
|
||||||
}),
|
}),
|
||||||
$executeRawUnsafe: vi.fn(async (statement: string) => {
|
$executeRawUnsafe: vi.fn(async (statement: string) => {
|
||||||
@@ -131,4 +150,24 @@ describe('API input-event change journal boundary', () => {
|
|||||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||||
expect(fixture.wake).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: {
|
turnDaemonLease: {
|
||||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
|
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
|
||||||
},
|
},
|
||||||
|
$queryRaw: vi.fn(async () => [{ running: true }]),
|
||||||
} as unknown as DatabaseClient,
|
} as unknown as DatabaseClient,
|
||||||
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
|
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
|
||||||
}) as unknown as GameApiContext;
|
}) as unknown as GameApiContext;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
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 databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
@@ -70,6 +70,7 @@ integration('message deletion tombstone persistence', () => {
|
|||||||
expect(rows).toHaveLength(2);
|
expect(rows).toHaveLength(2);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
expect(row.validUntil).toEqual(validUntil);
|
expect(row.validUntil).toEqual(validUntil);
|
||||||
|
expect(row.tombstonedAtWall).not.toBeNull();
|
||||||
expect(row.message).toMatchObject({
|
expect(row.message).toMatchObject({
|
||||||
text: '삭제된 메시지입니다.',
|
text: '삭제된 메시지입니다.',
|
||||||
option: { invalid: true },
|
option: { invalid: true },
|
||||||
@@ -81,4 +82,43 @@ integration('message deletion tombstone persistence', () => {
|
|||||||
})
|
})
|
||||||
).rejects.toBe(rollback);
|
).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(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 () => {
|
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();
|
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 () => {
|
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
||||||
const ambassador = {
|
const ambassador = {
|
||||||
...general,
|
...general,
|
||||||
@@ -259,7 +337,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.msgType).toBe('diplomacy');
|
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 () => {
|
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(result.msgType).toBe('diplomacy');
|
||||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
|
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||||
expect(changeJournal.snapshot()).toEqual([
|
expect(changeJournal.snapshot()).toEqual([
|
||||||
{ domain: 'messages.mailbox', entityId: 9000 },
|
{ domain: 'messages.mailbox', entityId: 9000 },
|
||||||
@@ -314,7 +391,6 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
|
|
||||||
expect(result.msgType).toBe('national');
|
expect(result.msgType).toBe('national');
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
|
||||||
@@ -555,7 +631,11 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('invalidates a recent owned message and its receiver copy', async () => {
|
it('invalidates a recent owned message and its receiver copy', async () => {
|
||||||
const queryRaw = vi.fn(async () => [
|
let rawCall = 0;
|
||||||
|
const queryRaw = vi.fn(async () => {
|
||||||
|
rawCall += 1;
|
||||||
|
if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
id: 21,
|
id: 21,
|
||||||
mailbox: general.id,
|
mailbox: general.id,
|
||||||
@@ -585,14 +665,16 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
option: { receiverMessageID: 22 },
|
option: { receiverMessageID: 22 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
});
|
||||||
const changeJournal = new ChangeJournal();
|
const changeJournal = new ChangeJournal();
|
||||||
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
||||||
|
|
||||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
|
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
|
||||||
|
|
||||||
expect(result.deletedIds).toEqual([21, 22]);
|
expect(result.deletedIds).toEqual([21, 22]);
|
||||||
expect(executeRaw).toHaveBeenCalledOnce();
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(executeRaw).not.toHaveBeenCalled();
|
||||||
expect(updateMany).not.toHaveBeenCalled();
|
expect(updateMany).not.toHaveBeenCalled();
|
||||||
expect(changeJournal.snapshot()).toEqual([
|
expect(changeJournal.snapshot()).toEqual([
|
||||||
{ domain: 'messages.mailbox', entityId: 7 },
|
{ domain: 'messages.mailbox', entityId: 7 },
|
||||||
@@ -601,7 +683,11 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
||||||
const queryRaw = vi.fn(async () => [
|
let rawCall = 0;
|
||||||
|
const queryRaw = vi.fn(async () => {
|
||||||
|
rawCall += 1;
|
||||||
|
if (rawCall > 1) return [{ id: 25 }];
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
id: 25,
|
id: 25,
|
||||||
mailbox: 9001,
|
mailbox: 9001,
|
||||||
@@ -631,13 +717,15 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
option: { receiverMessageID: 26 },
|
option: { receiverMessageID: 26 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
});
|
||||||
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
|
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
|
||||||
|
|
||||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
|
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
|
||||||
|
|
||||||
expect(result.deletedIds).toEqual([25]);
|
expect(result.deletedIds).toEqual([25]);
|
||||||
expect(executeRaw).toHaveBeenCalledOnce();
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(executeRaw).not.toHaveBeenCalled();
|
||||||
expect(updateMany).not.toHaveBeenCalled();
|
expect(updateMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -864,6 +952,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
const nationUpdate = vi.fn(async () => ({}));
|
const nationUpdate = vi.fn(async () => ({}));
|
||||||
const logCreateMany = vi.fn(async () => ({ count: 1 }));
|
const logCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||||
const messageUpdateMany = 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 cityUpdate = vi.fn(async () => ({}));
|
||||||
const changeJournal = new ChangeJournal();
|
const changeJournal = new ChangeJournal();
|
||||||
const { caller } = buildContext(
|
const { caller } = buildContext(
|
||||||
@@ -941,10 +1030,19 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
currentYear: 200,
|
currentYear: 200,
|
||||||
currentMonth: 3,
|
currentMonth: 3,
|
||||||
config: { environment: { mapName: 'che' } },
|
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 },
|
logEntry: { createMany: logCreateMany },
|
||||||
message: { updateMany: messageUpdateMany },
|
message: { updateMany: messageUpdateMany },
|
||||||
|
messageAction: { updateMany: messageActionUpdateMany },
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
},
|
},
|
||||||
{ changeJournal }
|
{ changeJournal }
|
||||||
@@ -987,7 +1085,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
);
|
);
|
||||||
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
|
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
|
||||||
where: { id: { in: [31] } },
|
where: { id: { in: [31] } },
|
||||||
data: { validUntil: expect.any(Date), validUntilTick: 0n },
|
data: { validUntil: expect.any(Date), validUntilTick: 1_000n },
|
||||||
});
|
});
|
||||||
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
|
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
|||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const bettingId = 990_071;
|
const bettingId = 990_071;
|
||||||
const concurrentBettingId = 990_072;
|
const concurrentBettingId = 990_072;
|
||||||
|
const phaseBettingId = 990_073;
|
||||||
const generalId = 9_971;
|
const generalId = 9_971;
|
||||||
const otherGeneralId = 9_972;
|
const otherGeneralId = 9_972;
|
||||||
|
const phaseGeneralId = 9_973;
|
||||||
const nationId = 990_071;
|
const nationId = 990_071;
|
||||||
const otherNationId = 990_072;
|
const otherNationId = 990_072;
|
||||||
const userId = 'nation-betting-router-user';
|
const userId = 'nation-betting-router-user';
|
||||||
const otherUserId = 'nation-betting-router-other-user';
|
const otherUserId = 'nation-betting-router-other-user';
|
||||||
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
||||||
|
const phaseUserId = 'nation-betting-router-phase-user';
|
||||||
|
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload = {
|
||||||
version: 1,
|
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', () => {
|
integration('nation betting router', () => {
|
||||||
let db: GamePrismaClient;
|
let db: GamePrismaClient;
|
||||||
let closeDb: (() => Promise<void>) | undefined;
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
@@ -90,12 +104,14 @@ integration('nation betting router', () => {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
closeDb = () => connector.disconnect();
|
closeDb = () => connector.disconnect();
|
||||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
await db.inputEvent.deleteMany({
|
||||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
|
||||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
});
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
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.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||||
|
|
||||||
await db.nation.createMany({
|
await db.nation.createMany({
|
||||||
@@ -138,6 +154,17 @@ integration('nation betting router', () => {
|
|||||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
meta: {},
|
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({
|
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({
|
await db.nationBetting.create({
|
||||||
data: {
|
data: {
|
||||||
id: concurrentBettingId,
|
id: concurrentBettingId,
|
||||||
@@ -184,17 +222,20 @@ integration('nation betting router', () => {
|
|||||||
data: [
|
data: [
|
||||||
{ userId, key: 'previous', value: 1_000 },
|
{ userId, key: 'previous', value: 1_000 },
|
||||||
{ userId: otherUserId, key: 'previous', value: 500 },
|
{ userId: otherUserId, key: 'previous', value: 500 },
|
||||||
|
{ userId: phaseUserId, key: 'previous', value: 500 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
await db.inputEvent.deleteMany({
|
||||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
|
||||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
});
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
||||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
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.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||||
await db.worldState.delete({ where: { id: worldStateId } });
|
await db.worldState.delete({ where: { id: worldStateId } });
|
||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
@@ -290,6 +331,51 @@ integration('nation betting router', () => {
|
|||||||
).toMatchObject({ value: 250 });
|
).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 () => {
|
it('requires authentication and an owned player general for every betting operation', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
|
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);
|
}, 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
|
const reservation = await appRouter
|
||||||
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
|
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
|
||||||
.join.listPossessCandidates({});
|
.join.listPossessCandidates({});
|
||||||
@@ -440,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
).rejects.toMatchObject({ code: 'TIMEOUT' });
|
).rejects.toMatchObject({ code: 'TIMEOUT' });
|
||||||
|
|
||||||
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||||
const acceptedGameAt = new Date(
|
expect(event.acceptedGameTick).toBeNull();
|
||||||
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time'
|
expect(event.processingGameTick).toBeNull();
|
||||||
);
|
expect(event.payload).not.toHaveProperty('acceptedGameAt');
|
||||||
expect(acceptedGameAt.toString()).not.toBe('Invalid Date');
|
const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!;
|
||||||
await db.npcSelectionToken.update({
|
await db.npcSelectionToken.update({
|
||||||
where: { ownerUserId: delayedUserId },
|
where: { ownerUserId: delayedUserId },
|
||||||
data: { validUntil: acceptedGameAt },
|
data: { validUntilTick: queuedAtTick },
|
||||||
});
|
});
|
||||||
await db.worldState.updateMany({
|
await db.worldState.updateMany({
|
||||||
data: { clockTick: { increment: 1 } },
|
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 startRuntime('npc-possession-delayed-retry-daemon');
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input)
|
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({
|
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
attempts: 1,
|
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);
|
}, 45_000);
|
||||||
|
|
||||||
it('serializes durable enqueue before a token refresh can replace its nonce', async () => {
|
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 createFixture = (rows: readonly object[]) => {
|
||||||
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]);
|
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 incr = vi.fn().mockResolvedValue(41);
|
||||||
const publish = vi.fn().mockResolvedValue(1);
|
const publish = vi.fn().mockResolvedValue(1);
|
||||||
const db = {
|
const db = {
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
readModelOutbox: { updateMany },
|
$executeRaw: executeRaw,
|
||||||
|
readModelOutbox: {},
|
||||||
} as unknown as ReadModelOutboxDatabase;
|
} as unknown as ReadModelOutboxDatabase;
|
||||||
const redis = { incr, publish } as unknown as RedisConnector['client'];
|
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', () => {
|
describe('ReadModelOutboxWorker', () => {
|
||||||
@@ -47,7 +48,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
|
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
|
||||||
@@ -64,7 +65,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
|
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
|
||||||
@@ -74,9 +75,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
revision: 41,
|
revision: 41,
|
||||||
changes: { frontStatusActorIds: [7] },
|
changes: { frontStatusActorIds: [7] },
|
||||||
});
|
});
|
||||||
expect(fixture.updateMany).toHaveBeenCalledWith(
|
expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"');
|
||||||
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
|
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
|
||||||
@@ -89,7 +88,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(fixture.incr).not.toHaveBeenCalled();
|
expect(fixture.incr).not.toHaveBeenCalled();
|
||||||
@@ -105,7 +104,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(fixture.incr).not.toHaveBeenCalled();
|
expect(fixture.incr).not.toHaveBeenCalled();
|
||||||
@@ -150,22 +149,15 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(onError).toHaveBeenCalledWith(
|
expect(onError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
|
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
|
||||||
);
|
);
|
||||||
expect(fixture.updateMany).toHaveBeenCalledWith(
|
const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
|
||||||
expect.objectContaining({
|
expect(releaseQuery.sql).toContain('"available_at"');
|
||||||
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null },
|
expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable'));
|
||||||
data: expect.objectContaining({
|
|
||||||
lockedAt: null,
|
|
||||||
lockOwner: null,
|
|
||||||
lastError: expect.stringContaining('redis unavailable'),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
|
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
|
||||||
|
|||||||
@@ -710,7 +710,7 @@ describe('appRouter', () => {
|
|||||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
).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 transport = new InMemoryTurnDaemonTransport();
|
||||||
const requestId = 'select-pool-reserve-http';
|
const requestId = 'select-pool-reserve-http';
|
||||||
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
|
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
|
||||||
@@ -741,8 +741,6 @@ describe('appRouter', () => {
|
|||||||
requestId: commandRequestId,
|
requestId: commandRequestId,
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
seedOwnerIdentity: '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))
|
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
|
||||||
?.some((candidate) => reservedNames.has(candidate.uniqueName))
|
?.some((candidate) => reservedNames.has(candidate.uniqueName))
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
await expect(
|
const reserveEvent = await db.inputEvent.findUniqueOrThrow({
|
||||||
db.inputEvent.findUniqueOrThrow({
|
|
||||||
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
||||||
})
|
});
|
||||||
).resolves.toMatchObject({
|
expect(reserveEvent).toMatchObject({
|
||||||
eventType: 'selectPoolReserve',
|
eventType: 'selectPoolReserve',
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
payload: {
|
processingGameTick: expect.anything(),
|
||||||
acceptedGameAt: expect.any(String),
|
|
||||||
acceptedGameTick: expect.any(Number),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
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 createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
|
||||||
const attempts = await Promise.allSettled([
|
const attempts = await Promise.allSettled([
|
||||||
@@ -357,6 +355,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
|
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
|
||||||
|
|
||||||
const cooledAt = '2026-07-29T00:00:00.000Z';
|
const cooledAt = '2026-07-29T00:00:00.000Z';
|
||||||
|
const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
|
||||||
await expect(
|
await expect(
|
||||||
turnDaemon.requestCommand({
|
turnDaemon.requestCommand({
|
||||||
type: 'patchGeneral',
|
type: 'patchGeneral',
|
||||||
@@ -366,6 +365,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
meta: {
|
meta: {
|
||||||
next_change: cooledAt,
|
next_change: cooledAt,
|
||||||
nextChangeAt: 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'))
|
.createCaller(buildContext('select-pool-reselect'))
|
||||||
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
|
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
|
||||||
).resolves.toEqual({ ok: true, generalId: initial.id });
|
).resolves.toEqual({ ok: true, generalId: initial.id });
|
||||||
await expect(
|
const reselectionEvent = await db.inputEvent.findUniqueOrThrow({
|
||||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } })
|
where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' },
|
||||||
).resolves.toMatchObject({
|
});
|
||||||
|
expect(reselectionEvent).toMatchObject({
|
||||||
eventType: 'selectPoolReselect',
|
eventType: 'selectPoolReselect',
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
payload: {
|
processingGameTick: expect.anything(),
|
||||||
acceptedGameAt: expect.any(String),
|
|
||||||
acceptedGameTick: expect.any(Number),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt');
|
||||||
|
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick');
|
||||||
|
|
||||||
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
|
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
|
||||||
expect(updated).toMatchObject({
|
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 },
|
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
const secondCooledAt = '2026-07-28T00:00:00.000Z';
|
const secondCooledAt = '2026-07-28T00:00:00.000Z';
|
||||||
|
const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
|
||||||
await turnDaemon.requestCommand({
|
await turnDaemon.requestCommand({
|
||||||
type: 'patchGeneral',
|
type: 'patchGeneral',
|
||||||
requestId: 'select-pool-full-cooldown-patch',
|
requestId: 'select-pool-full-cooldown-patch',
|
||||||
@@ -463,6 +464,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
meta: {
|
meta: {
|
||||||
next_change: secondCooledAt,
|
next_change: secondCooledAt,
|
||||||
nextChangeAt: secondCooledAt,
|
nextChangeAt: secondCooledAt,
|
||||||
|
next_change_tick: secondCooledTick,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -571,21 +573,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
.join.selectPoolGeneral(stableInput);
|
.join.selectPoolGeneral(stableInput);
|
||||||
expect(retried).toEqual(first);
|
expect(retried).toEqual(first);
|
||||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
|
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
|
||||||
await expect(
|
const stableEvent = await db.inputEvent.findUniqueOrThrow({
|
||||||
db.inputEvent.findUniqueOrThrow({
|
|
||||||
where: {
|
where: {
|
||||||
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
|
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
).resolves.toMatchObject({
|
expect(stableEvent).toMatchObject({
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
actorUserId: otherUserId,
|
actorUserId: otherUserId,
|
||||||
payload: {
|
processingGameTick: expect.anything(),
|
||||||
acceptedGameAt: expect.any(String),
|
|
||||||
acceptedGameTick: expect.any(Number),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt');
|
||||||
|
expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick');
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
|
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 { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
@@ -151,6 +151,8 @@ const buildContext = (options: {
|
|||||||
develCost?: number;
|
develCost?: number;
|
||||||
currentDevelCost?: number;
|
currentDevelCost?: number;
|
||||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||||
|
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
|
||||||
|
requestId?: string;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
const db = {
|
const db = {
|
||||||
general: {
|
general: {
|
||||||
@@ -168,7 +170,7 @@ const buildContext = (options: {
|
|||||||
clockTick: 0n,
|
clockTick: 0n,
|
||||||
clockMode: 'realtime',
|
clockMode: 'realtime',
|
||||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
clockPhase: 'RUNNING',
|
clockPhase: options.clockPhase ?? 'RUNNING',
|
||||||
clockRevision: 1n,
|
clockRevision: 1n,
|
||||||
deadlineGeneration: 1n,
|
deadlineGeneration: 1n,
|
||||||
tickSeconds: 60,
|
tickSeconds: 60,
|
||||||
@@ -178,6 +180,7 @@ const buildContext = (options: {
|
|||||||
},
|
},
|
||||||
} as unknown as DatabaseClient;
|
} as unknown as DatabaseClient;
|
||||||
return {
|
return {
|
||||||
|
requestId: options.requestId,
|
||||||
db,
|
db,
|
||||||
redis: options.redis as unknown as RedisConnector['client'],
|
redis: options.redis as unknown as RedisConnector['client'],
|
||||||
turnDaemon: options.transport,
|
turnDaemon: options.transport,
|
||||||
@@ -369,6 +372,83 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
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 () => {
|
it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const transport = new TournamentTransport();
|
const transport = new TournamentTransport();
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/t
|
|||||||
describe('turn engine status projection', () => {
|
describe('turn engine status projection', () => {
|
||||||
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
|
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
|
||||||
const activeLease = {
|
const activeLease = {
|
||||||
turnDaemonLease: {
|
$queryRaw: vi.fn(async () => [{ running: true }]),
|
||||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })),
|
} as any;
|
||||||
},
|
|
||||||
};
|
|
||||||
const now = new Date('2026-08-24T00:00:00.000Z');
|
const now = new Date('2026-08-24T00:00:00.000Z');
|
||||||
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
|
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
|
||||||
true
|
true
|
||||||
@@ -36,7 +34,7 @@ describe('turn engine status projection', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadTurnEngineRunning(
|
loadTurnEngineRunning(
|
||||||
source,
|
source,
|
||||||
{ turnDaemonLease: { findUnique: async () => null } },
|
{ $queryRaw: async () => [{ running: false }] } as any,
|
||||||
'che:default',
|
'che:default',
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
@@ -44,11 +42,7 @@ describe('turn engine status projection', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadTurnEngineRunning(
|
loadTurnEngineRunning(
|
||||||
source,
|
source,
|
||||||
{
|
{ $queryRaw: async () => [{ running: false }] } as any,
|
||||||
turnDaemonLease: {
|
|
||||||
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'che:default',
|
'che:default',
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
@@ -58,10 +52,10 @@ describe('turn engine status projection', () => {
|
|||||||
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
|
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
|
||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
const get = vi.fn(async () => 'RUNNING' as const);
|
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(
|
const cache = new CachedTurnEngineStatus(
|
||||||
{ get },
|
{ get },
|
||||||
{ turnDaemonLease: { findUnique } },
|
{ $queryRaw: queryRaw } as any,
|
||||||
'che:default',
|
'che:default',
|
||||||
2_000,
|
2_000,
|
||||||
() => now
|
() => now
|
||||||
@@ -75,6 +69,6 @@ describe('turn engine status projection', () => {
|
|||||||
now += 1;
|
now += 1;
|
||||||
await expect(cache.get()).resolves.toBe(true);
|
await expect(cache.get()).resolves.toBe(true);
|
||||||
expect(get).toHaveBeenCalledTimes(2);
|
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: 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: 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, tick: null })).toBe(true);
|
||||||
expect(
|
|
||||||
hasPollEnded(
|
|
||||||
{ closed_at: null, end_at: now, end_tick: null },
|
|
||||||
{ ...time, now: new Date(now.getTime() + 1), tick: null }
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unauthenticated survey access', async () => {
|
it('rejects unauthenticated survey access', async () => {
|
||||||
@@ -261,7 +255,6 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
voteId: 1,
|
voteId: 1,
|
||||||
generalId: 7,
|
generalId: 7,
|
||||||
selection: [0],
|
selection: [0],
|
||||||
acceptedGameTick: 100,
|
|
||||||
});
|
});
|
||||||
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
|
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
|
||||||
false
|
false
|
||||||
@@ -301,8 +294,6 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
const auth = buildAuth(['admin.survey.open']);
|
const auth = buildAuth(['admin.survey.open']);
|
||||||
const fixture = buildContext({ auth });
|
const fixture = buildContext({ auth });
|
||||||
const caller = appRouter.createCaller(fixture.context);
|
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.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
|
||||||
await expect(
|
await expect(
|
||||||
caller.vote.createPoll({
|
caller.vote.createPoll({
|
||||||
@@ -314,8 +305,6 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
).resolves.toEqual({ ok: true });
|
).resolves.toEqual({ ok: true });
|
||||||
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).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 });
|
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
|
||||||
const windowEnd = Date.now();
|
|
||||||
|
|
||||||
const mutationQueries = fixture.queryRaw.mock.calls
|
const mutationQueries = fixture.queryRaw.mock.calls
|
||||||
.map(([query]) => query)
|
.map(([query]) => query)
|
||||||
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(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 closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
|
||||||
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
|
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
|
||||||
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
|
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();
|
expect(query).toBeDefined();
|
||||||
const value = query?.values.at(index);
|
expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
|
||||||
expect(value).toBeInstanceOf(Date);
|
|
||||||
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
|
|
||||||
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
|
|
||||||
return value as Date;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(sqlText(commentInsert!)).toContain('created_at');
|
expect(sqlText(commentInsert!)).toContain('created_at');
|
||||||
expectCurrentDateAt(commentInsert, -1);
|
expectDbWallClock(commentInsert);
|
||||||
expect(sqlText(pollInsert!)).toContain('created_at');
|
expect(sqlText(pollInsert!)).toContain('created_at');
|
||||||
expect(sqlText(pollInsert!)).toContain('updated_at');
|
expect(sqlText(pollInsert!)).toContain('updated_at');
|
||||||
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2);
|
expectDbWallClock(pollInsert);
|
||||||
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
|
|
||||||
expect(pollUpdatedAt).toBe(pollCreatedAt);
|
|
||||||
|
|
||||||
expect(pollUpdates).toHaveLength(3);
|
expect(pollUpdates).toHaveLength(3);
|
||||||
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
|
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
|
||||||
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt);
|
expectDbWallClock(closePreviousUpdate);
|
||||||
expect(sqlText(editPollUpdate!)).toContain('updated_at');
|
expect(sqlText(editPollUpdate!)).toContain('updated_at');
|
||||||
expectCurrentDateAt(editPollUpdate, -2);
|
expectDbWallClock(editPollUpdate);
|
||||||
expect(sqlText(closePollUpdate!)).toContain('updated_at');
|
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 () => {
|
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 },
|
auction: { closeAt: Date; closeTick: bigint | null },
|
||||||
now: Date,
|
now: Date,
|
||||||
nowTick: number | null
|
nowTick: number | null
|
||||||
): boolean =>
|
): boolean => {
|
||||||
auction.closeTick !== null && nowTick !== null
|
void now;
|
||||||
? auction.closeTick < BigInt(nowTick)
|
return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
|
||||||
: auction.closeAt.getTime() < now.getTime();
|
};
|
||||||
|
|
||||||
export const resolveAuctionBidTiming = (
|
export const resolveAuctionBidTiming = (
|
||||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
|
||||||
processingNow: Date,
|
processingGameTick: number
|
||||||
acceptedGameTick?: number
|
): { bidAt: Date; bidTick: number } => {
|
||||||
): { bidAt: Date; bidTick: number } =>
|
if (!Number.isSafeInteger(processingGameTick)) {
|
||||||
acceptedGameTick === undefined
|
throw new Error('Auction bid requires an authoritative daemon processing game tick.');
|
||||||
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) }
|
}
|
||||||
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick };
|
return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
|
||||||
|
};
|
||||||
|
|
||||||
export const hasAuctionBidClosePassed = (
|
export const hasAuctionBidClosePassed = (
|
||||||
auction: { closeAt: Date; closeTick: bigint | null },
|
auction: { closeAt: Date; closeTick: bigint | null },
|
||||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
|
||||||
processingNow: Date,
|
processingGameTick: number
|
||||||
acceptedGameTick?: number
|
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick);
|
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
|
||||||
return hasAuctionClosePassed(auction, bidAt, bidTick);
|
return hasAuctionClosePassed(auction, bidAt, bidTick);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,15 +268,15 @@ export const createAuctionBidder = async (options: {
|
|||||||
reason: '경매가 종료되었습니다.',
|
reason: '경매가 종료되었습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const processingNow = world.getGameNow(new Date());
|
|
||||||
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
|
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
|
||||||
const { bidAt, bidTick } = resolveAuctionBidTiming(
|
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
|
||||||
world,
|
throw new Error('auctionBid requires an authoritative daemon processing game tick.');
|
||||||
processingNow,
|
}
|
||||||
typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick)
|
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
|
||||||
? convertedProcessingTick
|
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
|
||||||
: command.acceptedGameTick
|
throw new Error('auctionBid requires its durable input-event wall occurrence.');
|
||||||
);
|
}
|
||||||
|
const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick);
|
||||||
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
|
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
|
||||||
return {
|
return {
|
||||||
type: 'auctionBid',
|
type: 'auctionBid',
|
||||||
@@ -510,13 +510,24 @@ export const createAuctionBidder = async (options: {
|
|||||||
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
|
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
|
||||||
await tx.$executeRaw(
|
await tx.$executeRaw(
|
||||||
GamePrisma.sql`
|
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 (
|
VALUES (
|
||||||
${command.auctionId},
|
${command.auctionId},
|
||||||
${command.generalId},
|
${command.generalId},
|
||||||
${command.amount},
|
${command.amount},
|
||||||
${eventId},
|
${eventId},
|
||||||
${eventAt},
|
${eventAt},
|
||||||
|
${BigInt(bidTick)},
|
||||||
|
${requestedAtWall},
|
||||||
${JSON.stringify({
|
${JSON.stringify({
|
||||||
tryExtendCloseDate: command.tryExtendCloseDate ?? true,
|
tryExtendCloseDate: command.tryExtendCloseDate ?? true,
|
||||||
...(auction.type === 'UNIQUE_ITEM'
|
...(auction.type === 'UNIQUE_ITEM'
|
||||||
@@ -536,7 +547,7 @@ export const createAuctionBidder = async (options: {
|
|||||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||||
latest_event_id = ${eventId},
|
latest_event_id = ${eventId},
|
||||||
latest_event_at = ${eventAt},
|
latest_event_at = ${eventAt},
|
||||||
updated_at = ${eventAt}
|
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
WHERE id = ${command.auctionId}
|
WHERE id = ${command.auctionId}
|
||||||
AND status = 'OPEN'
|
AND status = 'OPEN'
|
||||||
AND latest_event_id = ${auction.latestEventId}
|
AND latest_event_id = ${auction.latestEventId}
|
||||||
@@ -556,7 +567,7 @@ export const createAuctionBidder = async (options: {
|
|||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE inheritance_point
|
UPDATE inheritance_point
|
||||||
SET value = value - ${morePoint},
|
SET value = value - ${morePoint},
|
||||||
updated_at = ${eventAt}
|
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
WHERE user_id = ${userId}
|
WHERE user_id = ${userId}
|
||||||
AND key = 'previous'
|
AND key = 'previous'
|
||||||
AND value >= ${morePoint}
|
AND value >= ${morePoint}
|
||||||
@@ -587,11 +598,16 @@ export const createAuctionBidder = async (options: {
|
|||||||
await tx.$executeRaw(
|
await tx.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
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)
|
ON CONFLICT (user_id, key)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
value = inheritance_point.value + EXCLUDED.value,
|
value = inheritance_point.value + EXCLUDED.value,
|
||||||
updated_at = EXCLUDED.updated_at
|
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
await tx.$executeRaw(
|
await tx.$executeRaw(
|
||||||
@@ -711,6 +727,7 @@ export const createAuctionBidder = async (options: {
|
|||||||
ok: true,
|
ok: true,
|
||||||
auctionId: command.auctionId,
|
auctionId: command.auctionId,
|
||||||
closeAt: nextCloseAt.toISOString(),
|
closeAt: nextCloseAt.toISOString(),
|
||||||
|
closeTick: world.dateToGameTick(nextCloseAt),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
close: async (): Promise<void> => {
|
close: async (): Promise<void> => {
|
||||||
|
|||||||
@@ -91,21 +91,17 @@ export const isAuctionFinalizeGenerationCurrent = (
|
|||||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||||
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
|
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (command.expectedCloseTick !== undefined) {
|
|
||||||
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
||||||
}
|
|
||||||
if (command.expectedCloseAt !== undefined) {
|
|
||||||
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hasAuctionFinalizeDeadlineArrived = (
|
export const hasAuctionFinalizeDeadlineArrived = (
|
||||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||||
now: Date,
|
now: Date,
|
||||||
nowTick: number
|
nowTick: number
|
||||||
): boolean =>
|
): boolean => {
|
||||||
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick);
|
void now;
|
||||||
|
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
|
||||||
|
};
|
||||||
|
|
||||||
export const buildAuctionBidderSystemMessage = (options: {
|
export const buildAuctionBidderSystemMessage = (options: {
|
||||||
bidder: TurnGeneral;
|
bidder: TurnGeneral;
|
||||||
@@ -293,7 +289,11 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
return { type: 'auctionFinalize', ok: true, auctionId };
|
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 (auction.status === 'OPEN') {
|
||||||
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
|
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
|
||||||
return {
|
return {
|
||||||
@@ -303,8 +303,7 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
reason: '경매 마감 세대가 변경되었습니다.',
|
reason: '경매 마감 세대가 변경되었습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const nowTick = world.dateToGameTick(now);
|
if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
|
||||||
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
|
|
||||||
return {
|
return {
|
||||||
type: 'auctionFinalize',
|
type: 'auctionFinalize',
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -316,8 +315,8 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
SET status = 'FINALIZING',
|
SET status = 'FINALIZING',
|
||||||
finalizing_at = ${now},
|
finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
updated_at = ${now}
|
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
WHERE id = ${auctionId}
|
WHERE id = ${auctionId}
|
||||||
AND status = 'OPEN'
|
AND status = 'OPEN'
|
||||||
`
|
`
|
||||||
@@ -364,8 +363,8 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
SET status = ${status},
|
SET status = ${status},
|
||||||
finished_at = ${now},
|
finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
updated_at = ${now}
|
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
WHERE id = ${auctionId}
|
WHERE id = ${auctionId}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ const openResourceAuction = async (
|
|||||||
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
|
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 turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
|
||||||
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
|
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
|
||||||
const auction = await db.auction.create({
|
const auction = await db.auction.create({
|
||||||
@@ -113,7 +117,7 @@ const openResourceAuction = async (
|
|||||||
},
|
},
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt,
|
closeAt,
|
||||||
openTick: BigInt(world.dateToGameTick(now)),
|
openTick: BigInt(processingGameTick),
|
||||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -125,6 +129,7 @@ const openResourceAuction = async (
|
|||||||
ok: true,
|
ok: true,
|
||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
closeAt: closeAt.toISOString(),
|
closeAt: closeAt.toISOString(),
|
||||||
|
closeTick: world.dateToGameTick(closeAt),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,8 +225,16 @@ const openUniqueAuction = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = world.getState();
|
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 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 closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
|
||||||
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
|
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
|
||||||
const extensionLimitMinutes = Math.max(
|
const extensionLimitMinutes = Math.max(
|
||||||
@@ -253,7 +266,7 @@ const openUniqueAuction = async (
|
|||||||
},
|
},
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt,
|
closeAt,
|
||||||
openTick: BigInt(world.dateToGameTick(now)),
|
openTick: BigInt(processingGameTick),
|
||||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||||
latestEventId: eventId,
|
latestEventId: eventId,
|
||||||
latestEventAt: now,
|
latestEventAt: now,
|
||||||
@@ -263,6 +276,8 @@ const openUniqueAuction = async (
|
|||||||
amount: command.amount,
|
amount: command.amount,
|
||||||
eventId,
|
eventId,
|
||||||
eventAt: now,
|
eventAt: now,
|
||||||
|
occurredGameTick: BigInt(processingGameTick),
|
||||||
|
requestedAtWall,
|
||||||
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
|
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -308,6 +323,7 @@ const openUniqueAuction = async (
|
|||||||
ok: true,
|
ok: true,
|
||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
closeAt: closeAt.toISOString(),
|
closeAt: closeAt.toISOString(),
|
||||||
|
closeTick: world.dateToGameTick(closeAt),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
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 { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -10,8 +11,9 @@ import type {
|
|||||||
TurnDaemonStatus,
|
TurnDaemonStatus,
|
||||||
} from './types.js';
|
} 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 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 {
|
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
|
||||||
private readonly localQueue: TurnDaemonCommand[] = [];
|
private readonly localQueue: TurnDaemonCommand[] = [];
|
||||||
@@ -35,8 +37,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
return local.concat(remote);
|
return local.concat(remote);
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||||
while (deadlineMs === null || Date.now() < deadlineMs) {
|
const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs);
|
||||||
|
while (deadline === null || performance.now() < deadline) {
|
||||||
const local = this.localQueue.shift();
|
const local = this.localQueue.shift();
|
||||||
if (local) {
|
if (local) {
|
||||||
return local;
|
return local;
|
||||||
@@ -45,7 +48,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
if (remote[0]) {
|
if (remote[0]) {
|
||||||
return 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);
|
await delay(remaining);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -84,35 +87,37 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const terminal = event.attempts >= this.maxAttempts;
|
const terminal = event.attempts >= this.maxAttempts;
|
||||||
await transaction.inputEvent.updateMany({
|
await transaction.$executeRaw(GamePrisma.sql`
|
||||||
where: {
|
UPDATE input_event
|
||||||
requestId,
|
SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus",
|
||||||
target: 'ENGINE',
|
processing_at = NULL,
|
||||||
status: 'PROCESSING',
|
locked_by = NULL,
|
||||||
lockedBy: this.workerId,
|
lease_until = NULL,
|
||||||
attempts: event.attempts,
|
completed_at = CASE
|
||||||
},
|
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
data: {
|
ELSE NULL
|
||||||
status: terminal ? 'FAILED' : 'PENDING',
|
END,
|
||||||
processingAt: null,
|
result = NULL,
|
||||||
lockedBy: null,
|
error = ${message}
|
||||||
leaseUntil: null,
|
WHERE request_id = ${requestId}
|
||||||
completedAt: terminal ? new Date() : null,
|
AND target = 'ENGINE'::"InputEventTarget"
|
||||||
result: GamePrisma.DbNull,
|
AND status = 'PROCESSING'::"InputEventStatus"
|
||||||
error: message,
|
AND locked_by = ${this.workerId}
|
||||||
},
|
AND attempts = ${event.attempts}
|
||||||
});
|
`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
|
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
|
||||||
await this.recoverExpiredLeases();
|
await this.recoverExpiredLeases();
|
||||||
return this.db.$transaction(async (transaction) => {
|
return this.db.$transaction(async (transaction) => {
|
||||||
|
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
||||||
const world = await transaction.worldState.findFirst({
|
const world = await transaction.worldState.findFirst({
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
|
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
|
||||||
});
|
});
|
||||||
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
|
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
|
||||||
|
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
|
||||||
const currentRevision = world?.clockRevision ?? null;
|
const currentRevision = world?.clockRevision ?? null;
|
||||||
const rows = await transaction.$queryRaw<
|
const rows = await transaction.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
@@ -141,6 +146,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
AND (
|
AND (
|
||||||
${gameplayAllowed}
|
${gameplayAllowed}
|
||||||
OR "event_type" = 'getStatus'
|
OR "event_type" = 'getStatus'
|
||||||
|
OR (
|
||||||
|
${suspendedTournamentBetCommand}
|
||||||
|
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
||||||
|
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
||||||
|
)
|
||||||
OR (
|
OR (
|
||||||
${world?.clockPhase === 'SUSPENDED'}
|
${world?.clockPhase === 'SUSPENDED'}
|
||||||
AND "event_type" = 'messageRespond'
|
AND "event_type" = 'messageRespond'
|
||||||
@@ -148,8 +158,14 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM "message" AS pending_message
|
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
|
WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer
|
||||||
AND pending_message."message" #>> '{option,action}' = 'raiseInvader'
|
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 (
|
AND EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -175,9 +191,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => {
|
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) {
|
if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) {
|
||||||
return row.acceptedGameTick ?? world?.clockTick ?? null;
|
return claimCoordinate.gameTick;
|
||||||
}
|
}
|
||||||
if (row.acceptedClockRevision > currentRevision) return undefined;
|
if (row.acceptedClockRevision > currentRevision) return undefined;
|
||||||
let revision = row.acceptedClockRevision;
|
let revision = row.acceptedClockRevision;
|
||||||
@@ -197,19 +213,25 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
entry.processingGameTick !== undefined
|
entry.processingGameTick !== undefined
|
||||||
);
|
);
|
||||||
for (const { row, processingGameTick } of processableRows) {
|
for (const { row, processingGameTick } of processableRows) {
|
||||||
await transaction.inputEvent.update({
|
await transaction.$executeRaw(GamePrisma.sql`
|
||||||
where: { sequence: row.sequence },
|
UPDATE input_event
|
||||||
data: {
|
SET status = 'PROCESSING'::"InputEventStatus",
|
||||||
status: 'PROCESSING',
|
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
processingAt: new Date(),
|
accepted_game_tick = COALESCE(accepted_game_tick, ${processingGameTick}),
|
||||||
processingGameTick,
|
accepted_clock_revision = COALESCE(accepted_clock_revision, ${currentRevision}),
|
||||||
processingClockRevision: currentRevision,
|
accepted_deadline_generation = COALESCE(
|
||||||
processingDeadlineGeneration: world?.deadlineGeneration ?? null,
|
accepted_deadline_generation,
|
||||||
lockedBy: this.workerId,
|
${world?.deadlineGeneration ?? null}
|
||||||
leaseUntil: new Date(Date.now() + this.leaseDurationMs),
|
),
|
||||||
attempts: { increment: 1 },
|
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[] = [];
|
const commands: TurnDaemonCommand[] = [];
|
||||||
@@ -220,39 +242,34 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
command: row.payload as TurnDaemonCommand,
|
command: row.payload as TurnDaemonCommand,
|
||||||
});
|
});
|
||||||
if (!command) {
|
if (!command) {
|
||||||
await transaction.inputEvent.update({
|
await transaction.$executeRaw(GamePrisma.sql`
|
||||||
where: { sequence: row.sequence },
|
UPDATE input_event
|
||||||
data: {
|
SET status = 'FAILED'::"InputEventStatus",
|
||||||
status: 'FAILED',
|
error = ${`Invalid command payload for ${row.eventType}`},
|
||||||
error: `Invalid command payload for ${row.eventType}`,
|
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
completedAt: new Date(),
|
locked_by = NULL,
|
||||||
lockedBy: null,
|
lease_until = NULL
|
||||||
leaseUntil: null,
|
WHERE sequence = ${row.sequence}
|
||||||
},
|
`);
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (processingGameTick !== null) {
|
||||||
processingGameTick !== null &&
|
|
||||||
row.acceptedGameTick !== null &&
|
|
||||||
processingGameTick !== row.acceptedGameTick
|
|
||||||
) {
|
|
||||||
const value = Number(processingGameTick);
|
const value = Number(processingGameTick);
|
||||||
if (!Number.isSafeInteger(value)) {
|
if (!Number.isSafeInteger(value)) {
|
||||||
await transaction.inputEvent.update({
|
await transaction.$executeRaw(GamePrisma.sql`
|
||||||
where: { sequence: row.sequence },
|
UPDATE input_event
|
||||||
data: {
|
SET status = 'FAILED'::"InputEventStatus",
|
||||||
status: 'FAILED',
|
error = 'Converted processing game tick is outside the safe integer range.',
|
||||||
error: 'Converted processing game tick is outside the safe integer range.',
|
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
completedAt: new Date(),
|
locked_by = NULL,
|
||||||
lockedBy: null,
|
lease_until = NULL
|
||||||
leaseUntil: null,
|
WHERE sequence = ${row.sequence}
|
||||||
},
|
`);
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Reflect.set(command, 'processingGameTick', value);
|
Reflect.set(command, 'processingGameTick', value);
|
||||||
}
|
}
|
||||||
|
Reflect.set(command, 'requestedAtWall', row.createdAt);
|
||||||
commands.push(command);
|
commands.push(command);
|
||||||
}
|
}
|
||||||
return commands;
|
return commands;
|
||||||
@@ -260,23 +277,20 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async complete(requestId: string, result: unknown): Promise<void> {
|
private async complete(requestId: string, result: unknown): Promise<void> {
|
||||||
const completed = await this.db.inputEvent.updateMany({
|
const completed = await this.db.$executeRaw(GamePrisma.sql`
|
||||||
where: {
|
UPDATE input_event
|
||||||
requestId,
|
SET status = 'SUCCEEDED'::"InputEventStatus",
|
||||||
target: 'ENGINE',
|
result = CAST(${serializeResult(result)} AS jsonb),
|
||||||
status: 'PROCESSING',
|
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
lockedBy: this.workerId,
|
error = NULL,
|
||||||
},
|
locked_by = NULL,
|
||||||
data: {
|
lease_until = NULL
|
||||||
status: 'SUCCEEDED',
|
WHERE request_id = ${requestId}
|
||||||
result: asJson(result),
|
AND target = 'ENGINE'::"InputEventTarget"
|
||||||
completedAt: new Date(),
|
AND status = 'PROCESSING'::"InputEventStatus"
|
||||||
error: null,
|
AND locked_by = ${this.workerId}
|
||||||
lockedBy: null,
|
`);
|
||||||
leaseUntil: null,
|
if (completed > 0) {
|
||||||
},
|
|
||||||
});
|
|
||||||
if (completed.count > 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Database hooks commit mutation results atomically with game state and
|
// 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> {
|
private async recoverExpiredLeases(): Promise<void> {
|
||||||
const now = new Date();
|
await this.db.$executeRaw(GamePrisma.sql`
|
||||||
await this.db.inputEvent.updateMany({
|
UPDATE input_event
|
||||||
where: {
|
SET status = 'PENDING'::"InputEventStatus",
|
||||||
target: 'ENGINE',
|
processing_at = NULL,
|
||||||
status: 'PROCESSING',
|
locked_by = NULL,
|
||||||
leaseUntil: { lt: now },
|
lease_until = NULL
|
||||||
},
|
WHERE target = 'ENGINE'::"InputEventTarget"
|
||||||
data: {
|
AND status = 'PROCESSING'::"InputEventStatus"
|
||||||
status: 'PENDING',
|
AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
processingAt: null,
|
`);
|
||||||
lockedBy: null,
|
|
||||||
leaseUntil: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
|
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
|
||||||
|
|
||||||
type Waiter = {
|
type Waiter = {
|
||||||
deadlineMs: number | null;
|
timeoutMs: number | null;
|
||||||
resolve: (command: TurnDaemonCommand | null) => void;
|
resolve: (command: TurnDaemonCommand | null) => void;
|
||||||
timeoutId?: ReturnType<typeof setTimeout>;
|
timeoutId?: ReturnType<typeof setTimeout>;
|
||||||
};
|
};
|
||||||
@@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue {
|
|||||||
return drained;
|
return drained;
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||||
if (this.queue.length > 0) {
|
if (this.queue.length > 0) {
|
||||||
return this.queue.shift() ?? null;
|
return this.queue.shift() ?? null;
|
||||||
}
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const waiter: Waiter = { deadlineMs, resolve };
|
const waiter: Waiter = { timeoutMs, resolve };
|
||||||
if (deadlineMs !== null) {
|
if (timeoutMs !== null) {
|
||||||
const delay = Math.max(0, deadlineMs - Date.now());
|
const delay = Math.max(0, timeoutMs);
|
||||||
waiter.timeoutId = setTimeout(() => {
|
waiter.timeoutId = setTimeout(() => {
|
||||||
this.removeWaiter(waiter);
|
this.removeWaiter(waiter);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
|
|||||||
@@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur
|
|||||||
return drained.concat(remote);
|
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) {
|
if (this.localQueue.length > 0) {
|
||||||
return this.localQueue.shift() ?? null;
|
return this.localQueue.shift() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now());
|
const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs);
|
||||||
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000);
|
const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000);
|
||||||
if (deadlineMs !== null && blockMs === 0) {
|
if (timeoutMs !== null && blockMs === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export class TurnDaemonLifecycle {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs));
|
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
|
||||||
if (command) {
|
if (command) {
|
||||||
await this.handleCommand(command);
|
await this.handleCommand(command);
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ export class TurnDaemonLifecycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async waitForResume(): Promise<void> {
|
private async waitForResume(): Promise<void> {
|
||||||
const command = await this.controlQueue.waitUntil(null);
|
const command = await this.controlQueue.waitFor(null);
|
||||||
if (command) {
|
if (command) {
|
||||||
await this.handleCommand(command);
|
await this.handleCommand(command);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export interface TurnStateStore {
|
|||||||
export interface TurnDaemonControlQueue {
|
export interface TurnDaemonControlQueue {
|
||||||
enqueue(command: TurnDaemonCommand): void;
|
enqueue(command: TurnDaemonCommand): void;
|
||||||
drain(): Promise<TurnDaemonCommand[]>;
|
drain(): Promise<TurnDaemonCommand[]>;
|
||||||
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>;
|
waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null>;
|
||||||
getDepth(): number;
|
getDepth(): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ interface MessageRow {
|
|||||||
type: string;
|
type: string;
|
||||||
time: Date;
|
time: Date;
|
||||||
validUntil: Date;
|
validUntil: Date;
|
||||||
|
actionType: string;
|
||||||
|
actionStatus: string;
|
||||||
|
createdGameTick: bigint;
|
||||||
|
expiresGameTick: bigint | null;
|
||||||
message: unknown;
|
message: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,11 +102,16 @@ const invalidateMessageIds = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
||||||
if (uniqueIds.length === 0) return;
|
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({
|
await db.message.updateMany({
|
||||||
where: { id: { in: uniqueIds } },
|
where: { id: { in: uniqueIds } },
|
||||||
data: {
|
data: {
|
||||||
validUntil: now,
|
validUntil: now,
|
||||||
validUntilTick: BigInt(world.dateToGameTick(now)),
|
validUntilTick: resolvedGameTick,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -113,40 +122,53 @@ const validateActor = async (options: {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
}): Promise<Date> => {
|
}): Promise<{ processingGameTick: number }> => {
|
||||||
const actor = options.world.getGeneralById(options.generalId);
|
const actor = options.world.getGeneralById(options.generalId);
|
||||||
if (!actor || actor.userId !== options.userId) {
|
if (!actor || actor.userId !== options.userId) {
|
||||||
throw new Error('messageRespond general owner does not match command user.');
|
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({
|
const event = await options.db.inputEvent.findUnique({
|
||||||
where: { requestId: options.requestId },
|
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) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
|
||||||
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
|
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
|
||||||
throw new Error('ENGINE input event actor or type does not match 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 (
|
const fetchMessageForUpdate = async (
|
||||||
db: GamePrisma.TransactionClient,
|
db: GamePrisma.TransactionClient,
|
||||||
world: InMemoryTurnWorld,
|
|
||||||
messageId: number,
|
messageId: number,
|
||||||
now: Date
|
currentGameTick: number
|
||||||
): Promise<MessageRow | null> => {
|
): Promise<MessageRow | null> => {
|
||||||
const currentTick = BigInt(world.dateToGameTick(now));
|
|
||||||
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
|
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
|
||||||
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
|
SELECT
|
||||||
FROM message
|
envelope.id,
|
||||||
WHERE id = ${messageId}
|
envelope.mailbox,
|
||||||
AND (
|
envelope.type,
|
||||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
|
envelope.time,
|
||||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
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
|
LIMIT 1
|
||||||
FOR UPDATE
|
FOR UPDATE OF envelope, action
|
||||||
`);
|
`);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
};
|
};
|
||||||
@@ -165,7 +187,7 @@ const respondToScout = async (options: {
|
|||||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||||
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
|
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: '유효하지 않은 등용장입니다.' };
|
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,18 +208,17 @@ const respondToScout = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||||
SELECT id
|
SELECT envelope.id
|
||||||
FROM message
|
FROM message AS envelope
|
||||||
WHERE mailbox = ${payload.src.generalId}
|
JOIN message_action AS action ON action.message_id = envelope.id
|
||||||
AND type = 'private'
|
WHERE envelope.mailbox = ${payload.src.generalId}
|
||||||
AND dest = mailbox
|
AND envelope.type = 'private'
|
||||||
AND id <> ${row.id}
|
AND envelope.dest = envelope.mailbox
|
||||||
AND (
|
AND envelope.id <> ${row.id}
|
||||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
|
AND action.status = 'PENDING'
|
||||||
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
|
||||||
)
|
AND action.action_type = 'scout'
|
||||||
AND message->'option'->>'action' = 'scout'
|
FOR UPDATE OF envelope, action
|
||||||
FOR UPDATE
|
|
||||||
`);
|
`);
|
||||||
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
|
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
|
||||||
world.queueMessage({
|
world.queueMessage({
|
||||||
@@ -327,6 +348,7 @@ const respondToRaiseInvader = async (options: {
|
|||||||
},
|
},
|
||||||
event
|
event
|
||||||
);
|
);
|
||||||
|
await invalidateMessageIds(db, world, [row.id], world.gameTickToDate(alignment.alignedTick));
|
||||||
return { ok: true, action: 'raiseInvader', reason: 'success' };
|
return { ok: true, action: 'raiseInvader', reason: 'success' };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -354,13 +376,14 @@ export const respondToActionableMessage = async (options: {
|
|||||||
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
||||||
}) => Promise<ClockReconciliationResult>;
|
}) => Promise<ClockReconciliationResult>;
|
||||||
}): Promise<ActionableMessageResponseResult> => {
|
}): Promise<ActionableMessageResponseResult> => {
|
||||||
const acceptedAt = await validateActor(options);
|
const accepted = await validateActor(options);
|
||||||
const now = options.world.getGameNow(acceptedAt);
|
const now = options.world.gameTickToDate(accepted.processingGameTick);
|
||||||
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
|
const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
|
||||||
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
||||||
const payload = parsePayload(row.message);
|
const payload = parsePayload(row.message);
|
||||||
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
||||||
const action = asRecord(payload.option).action;
|
const action = asRecord(payload.option).action;
|
||||||
|
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
|
||||||
if (action === 'scout') {
|
if (action === 'scout') {
|
||||||
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
|
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;
|
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 general ORDER BY id FOR UPDATE`);
|
||||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||||
SELECT id FROM auction
|
SELECT id FROM auction
|
||||||
@@ -155,10 +155,18 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, cutTick: bigin
|
|||||||
ORDER BY id FOR UPDATE
|
ORDER BY id FOR UPDATE
|
||||||
`);
|
`);
|
||||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||||
SELECT id FROM message
|
SELECT bid.id
|
||||||
WHERE valid_until_tick IS NOT NULL AND valid_until_tick >= ${cutTick}
|
FROM auction_bid AS bid
|
||||||
ORDER BY id FOR UPDATE
|
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`
|
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
||||||
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
|
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
|
||||||
`);
|
`);
|
||||||
@@ -175,7 +183,8 @@ const readParticipantSnapshots = async (
|
|||||||
worldStateId: number,
|
worldStateId: number,
|
||||||
cutTick: bigint
|
cutTick: bigint
|
||||||
): Promise<ParticipantSnapshot[]> => {
|
): 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({
|
db.worldState.findUniqueOrThrow({
|
||||||
where: { id: worldStateId },
|
where: { id: worldStateId },
|
||||||
select: {
|
select: {
|
||||||
@@ -188,17 +197,32 @@ const readParticipantSnapshots = async (
|
|||||||
}),
|
}),
|
||||||
db.general.findMany({
|
db.general.findMany({
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { id: true, turnTick: true, recentWarTick: true },
|
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
|
||||||
}),
|
}),
|
||||||
db.auction.findMany({
|
db.auction.findMany({
|
||||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { id: true, status: true, openTick: true, closeTick: true },
|
select: { id: true, status: true, openTick: true, closeTick: true },
|
||||||
}),
|
}),
|
||||||
db.message.findMany({
|
db.auctionBid.findMany({
|
||||||
where: { validUntilTick: { not: null, gte: cutTick } },
|
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
|
||||||
orderBy: { id: 'asc' },
|
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({
|
db.votePoll.findMany({
|
||||||
where: { closedAt: null },
|
where: { closedAt: null },
|
||||||
@@ -246,6 +270,18 @@ const readParticipantSnapshots = async (
|
|||||||
'KEEP',
|
'KEEP',
|
||||||
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick }))
|
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(
|
snapshot(
|
||||||
'auction-open-occurrence',
|
'auction-open-occurrence',
|
||||||
'KEEP',
|
'KEEP',
|
||||||
@@ -256,21 +292,34 @@ const readParticipantSnapshots = async (
|
|||||||
'SHIFT',
|
'SHIFT',
|
||||||
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
|
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
|
||||||
),
|
),
|
||||||
|
snapshot('auction-bid-occurrence', 'KEEP', auctionBids),
|
||||||
snapshot(
|
snapshot(
|
||||||
'auction-finalizing-recovery',
|
'auction-finalizing-recovery',
|
||||||
'REBUILD',
|
'REBUILD',
|
||||||
auctions.map(({ id, status }) => ({ id, status }))
|
auctions.map(({ id, status }) => ({ id, status }))
|
||||||
),
|
),
|
||||||
snapshot(
|
snapshot(
|
||||||
'message-occurrence',
|
'message-action-occurrence',
|
||||||
'KEEP',
|
'KEEP',
|
||||||
messages.map(({ id, timeTick }) => ({ id, timeTick }))
|
messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick }))
|
||||||
),
|
),
|
||||||
snapshot(
|
snapshot(
|
||||||
'message-expiry',
|
'message-action-expiry',
|
||||||
'SHIFT',
|
'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(
|
snapshot(
|
||||||
'vote-start-occurrence',
|
'vote-start-occurrence',
|
||||||
'KEEP',
|
'KEEP',
|
||||||
@@ -283,7 +332,7 @@ const readParticipantSnapshots = async (
|
|||||||
),
|
),
|
||||||
snapshot('select-pool-reservation', 'SHIFT', pool),
|
snapshot('select-pool-reservation', 'SHIFT', pool),
|
||||||
snapshot('npc-selection-window', 'SHIFT', npcTokens),
|
snapshot('npc-selection-window', 'SHIFT', npcTokens),
|
||||||
snapshot('accepted-command-coordinate', 'KEEP', commands),
|
snapshot('daemon-command-coordinate', 'KEEP', commands),
|
||||||
snapshot('movable-json-rule-anchors', 'SHIFT', [
|
snapshot('movable-json-rule-anchors', 'SHIFT', [
|
||||||
{
|
{
|
||||||
lastTurnTime: Reflect.get(meta, 'lastTurnTime'),
|
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 assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
||||||
const shift = BigInt(shiftTicks);
|
const shift = BigInt(shiftTicks);
|
||||||
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
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.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({
|
db.auction.aggregate({
|
||||||
_max: { closeTick: true },
|
_max: { closeTick: true },
|
||||||
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
|
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
|
||||||
}),
|
}),
|
||||||
db.message.aggregate({
|
db.messageAction.aggregate({
|
||||||
_max: { validUntilTick: true },
|
_max: { expiresGameTick: true },
|
||||||
where: { validUntilTick: { not: null, lt: BigInt(MAX_SAFE_GAME_TICK) } },
|
where: { status: 'PENDING', expiresGameTick: { not: null } },
|
||||||
}),
|
}),
|
||||||
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
|
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
|
||||||
db.selectPoolEntry.aggregate({
|
db.selectPoolEntry.aggregate({
|
||||||
@@ -452,8 +506,9 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
|
|||||||
]);
|
]);
|
||||||
const values: Array<[string, bigint | null]> = [
|
const values: Array<[string, bigint | null]> = [
|
||||||
['general.turn_tick', general._max.turnTick],
|
['general.turn_tick', general._max.turnTick],
|
||||||
|
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
|
||||||
['auction.close_tick', auction._max.closeTick],
|
['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],
|
['vote_poll.end_tick', vote._max.endTick],
|
||||||
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
|
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
|
||||||
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
|
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
|
||||||
@@ -493,6 +548,32 @@ const applyParticipantShift = async (
|
|||||||
WHERE turn_tick IS NOT NULL
|
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(
|
affected.set(
|
||||||
'auction-deadline',
|
'auction-deadline',
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
await db.$executeRaw(GamePrisma.sql`
|
||||||
@@ -504,14 +585,37 @@ const applyParticipantShift = async (
|
|||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
affected.set(
|
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`
|
await db.$executeRaw(GamePrisma.sql`
|
||||||
UPDATE message
|
WITH shifted AS (
|
||||||
SET valid_until_tick = valid_until_tick + ${shiftTicks},
|
UPDATE message_action
|
||||||
valid_until = valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
SET expires_game_tick = expires_game_tick + ${shiftTicks},
|
||||||
WHERE valid_until_tick IS NOT NULL
|
clock_revision = ${targetRevision},
|
||||||
AND valid_until_tick >= ${cutTick}
|
deadline_generation = ${targetGeneration},
|
||||||
AND valid_until_tick < ${BigInt(MAX_SAFE_GAME_TICK)}
|
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(
|
affected.set(
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({
|
|||||||
type: z.literal('auctionFinalize'),
|
type: z.literal('auctionFinalize'),
|
||||||
auctionId: zFiniteNumber,
|
auctionId: zFiniteNumber,
|
||||||
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||||
expectedCloseTick: zSafeInteger.optional(),
|
expectedCloseTick: zSafeInteger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const zAuctionOpen = z.object({
|
const zAuctionOpen = z.object({
|
||||||
@@ -60,7 +60,6 @@ const zAuctionBid = z.object({
|
|||||||
auctionId: zFiniteNumber,
|
auctionId: zFiniteNumber,
|
||||||
generalId: zFiniteNumber,
|
generalId: zFiniteNumber,
|
||||||
amount: zFiniteNumber,
|
amount: zFiniteNumber,
|
||||||
acceptedGameTick: zSafeInteger.optional(),
|
|
||||||
tryExtendCloseDate: z.boolean().optional(),
|
tryExtendCloseDate: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,7 +426,7 @@ const zSelectPoolReserve = z
|
|||||||
requestId: z.string().optional(),
|
requestId: z.string().optional(),
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
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(),
|
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
writeReadModelChangeJournal,
|
writeReadModelChangeJournal,
|
||||||
enqueuePrivateMessageWebPush,
|
enqueuePrivateMessageWebPush,
|
||||||
enqueueWebPushOutboxEvents,
|
enqueueWebPushOutboxEvents,
|
||||||
|
persistMessageEnvelope,
|
||||||
type InputJsonValue,
|
type InputJsonValue,
|
||||||
type ReadModelJournalWriteResult,
|
type ReadModelJournalWriteResult,
|
||||||
type TurnEngineCityUpdateInput,
|
type TurnEngineCityUpdateInput,
|
||||||
@@ -1862,37 +1863,18 @@ export const createDatabaseTurnHooks = async (
|
|||||||
await sendMessage(
|
await sendMessage(
|
||||||
{
|
{
|
||||||
insertMessage: async (draft: MessageRecordDraft) => {
|
insertMessage: async (draft: MessageRecordDraft) => {
|
||||||
const toTickOrNull = (date: Date): bigint | null => {
|
const clock = world.getGameClockState();
|
||||||
try {
|
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
|
||||||
return BigInt(world.dateToGameTick(date));
|
const expiresGameTick =
|
||||||
} catch {
|
typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000
|
||||||
// Legacy messages may use year 9999 as an
|
? null
|
||||||
// effectively-unbounded expiry, beyond the
|
: BigInt(world.dateToGameTick(draft.validUntil));
|
||||||
// safe JavaScript tick range.
|
const id = await persistMessageEnvelope(prisma, draft, {
|
||||||
return null;
|
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
|
||||||
}
|
clockRevision: BigInt(clock.revision),
|
||||||
};
|
deadlineGeneration: BigInt(clock.deadlineGeneration),
|
||||||
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
|
expiresGameTick,
|
||||||
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.');
|
|
||||||
}
|
|
||||||
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
||||||
persistedMessageMailboxes.push(draft.mailbox);
|
persistedMessageMailboxes.push(draft.mailbox);
|
||||||
return id;
|
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';
|
import { isRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
|
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
|
||||||
@@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const terminal = result.status !== 'PARTIAL';
|
const terminal = result.status !== 'PARTIAL';
|
||||||
const updated = await prisma.gatewayRuntimeAction.updateMany({
|
const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6));
|
||||||
where: {
|
const updated = await prisma.$queryRaw<Array<{ id: string }>>(GatewayPrisma.sql`
|
||||||
id: action.id,
|
UPDATE gateway_runtime_action
|
||||||
status: { in: ['REQUESTED', 'PARTIAL'] },
|
SET status = ${result.status}::"GatewayRuntimeActionStatus",
|
||||||
},
|
detail = ${result.detail ?? null},
|
||||||
data: {
|
handler = 'turn-daemon',
|
||||||
status: result.status,
|
handled_at = CASE
|
||||||
detail: result.detail ?? null,
|
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
handler: 'turn-daemon',
|
ELSE NULL
|
||||||
handledAt: terminal ? new Date() : null,
|
END,
|
||||||
attempts: { increment: 1 },
|
attempts = attempts + 1,
|
||||||
nextAttemptAt: terminal
|
next_attempt_at = CASE
|
||||||
? null
|
WHEN ${terminal} THEN NULL
|
||||||
: new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))),
|
ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||||
},
|
+ ${retryDelayMs} * INTERVAL '1 millisecond'
|
||||||
});
|
END,
|
||||||
if (terminal && updated.count > 0) {
|
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);
|
await options.onActionApplied?.(actionRecord, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
||||||
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
|
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
|
||||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
|||||||
return {
|
return {
|
||||||
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
|
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
|
||||||
async shouldPause(): Promise<boolean> {
|
async shouldPause(): Promise<boolean> {
|
||||||
const now = Date.now();
|
const now = performance.now();
|
||||||
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
|
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
|
||||||
return cachedPause;
|
return cachedPause;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
||||||
import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js';
|
import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js';
|
||||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||||
import type { InMemoryTurnWorld, TurnCalendarContext } from './inMemoryWorld.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> {
|
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 deadlineMs = startMs + Math.max(0, budget.budgetMs);
|
||||||
const isBudgetExpired = () => Date.now() >= deadlineMs;
|
const isBudgetExpired = () => performance.now() >= deadlineMs;
|
||||||
|
|
||||||
if (isWorldUnited(this.world)) {
|
if (isWorldUnited(this.world)) {
|
||||||
return {
|
return {
|
||||||
lastTurnTime: this.world.getState().lastTurnTime.toISOString(),
|
lastTurnTime: this.world.getState().lastTurnTime.toISOString(),
|
||||||
processedGenerals: 0,
|
processedGenerals: 0,
|
||||||
processedTurns: 0,
|
processedTurns: 0,
|
||||||
durationMs: Math.max(0, Date.now() - startMs),
|
durationMs: Math.max(0, performance.now() - startMs),
|
||||||
partial: false,
|
partial: false,
|
||||||
checkpoint,
|
checkpoint,
|
||||||
};
|
};
|
||||||
@@ -171,7 +173,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
|||||||
lastTurnTime,
|
lastTurnTime,
|
||||||
processedGenerals,
|
processedGenerals,
|
||||||
processedTurns,
|
processedTurns,
|
||||||
durationMs: Math.max(0, Date.now() - startMs),
|
durationMs: Math.max(0, performance.now() - startMs),
|
||||||
partial,
|
partial,
|
||||||
checkpoint: nextCheckpoint,
|
checkpoint: nextCheckpoint,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export const resolveOwnerDisplayName = (rawMeta: unknown): string => {
|
|||||||
return '알수없음';
|
return '알수없음';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const executeInheritanceAction = async (options: {
|
const executeInheritanceActionMutation = async (options: {
|
||||||
db: GamePrisma.TransactionClient;
|
db: GamePrisma.TransactionClient;
|
||||||
world: InMemoryTurnWorld;
|
world: InMemoryTurnWorld;
|
||||||
command: InheritanceActionCommand;
|
command: InheritanceActionCommand;
|
||||||
@@ -668,3 +668,59 @@ export const executeInheritanceAction = async (options: {
|
|||||||
});
|
});
|
||||||
return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost };
|
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 { 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 {
|
import {
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
@@ -84,7 +84,9 @@ export interface NpcPossessionSelectionObserver {
|
|||||||
interface NpcSelectionTokenRow {
|
interface NpcSelectionTokenRow {
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
validUntil: Date;
|
validUntil: Date;
|
||||||
|
validUntilTick: bigint | null;
|
||||||
pickMoreFrom: Date;
|
pickMoreFrom: Date;
|
||||||
|
pickMoreFromTick: bigint | null;
|
||||||
pickResult: unknown;
|
pickResult: unknown;
|
||||||
nonce: number;
|
nonce: number;
|
||||||
}
|
}
|
||||||
@@ -105,8 +107,8 @@ const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTi
|
|||||||
export const buildNpcSelectionTokenSeed = (
|
export const buildNpcSelectionTokenSeed = (
|
||||||
hiddenSeed: string | number,
|
hiddenSeed: string | number,
|
||||||
ownerIdentity: string | number,
|
ownerIdentity: string | number,
|
||||||
acceptedGameTick: number
|
createdGameTick: number
|
||||||
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick);
|
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, createdGameTick);
|
||||||
|
|
||||||
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||||
const meta = asRecord(worldState.meta);
|
const meta = asRecord(worldState.meta);
|
||||||
@@ -159,15 +161,22 @@ const parsePickResult = (value: unknown): Record<string, NpcPossessionCandidate>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toReservation = (
|
const toReservation = (
|
||||||
token: Pick<NpcSelectionTokenRow, 'validUntil' | 'pickMoreFrom' | 'pickResult' | 'nonce'>,
|
token: Pick<
|
||||||
now: Date
|
NpcSelectionTokenRow,
|
||||||
|
'validUntil' | 'validUntilTick' | 'pickMoreFrom' | 'pickMoreFromTick' | 'pickResult' | 'nonce'
|
||||||
|
>,
|
||||||
|
currentGameTick: number,
|
||||||
|
ticksPerSecond: number
|
||||||
): NpcPossessionReservation => {
|
): NpcPossessionReservation => {
|
||||||
|
if (token.validUntilTick === null || token.pickMoreFromTick === null) {
|
||||||
|
return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보의 GAME_TIME 기한이 없습니다.');
|
||||||
|
}
|
||||||
const pickResult = parsePickResult(token.pickResult);
|
const pickResult = parsePickResult(token.pickResult);
|
||||||
return {
|
return {
|
||||||
tokenNonce: token.nonce,
|
tokenNonce: token.nonce,
|
||||||
validUntil: token.validUntil.toISOString(),
|
validUntil: token.validUntil.toISOString(),
|
||||||
pickMoreFrom: token.pickMoreFrom.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(
|
candidates: Object.values(pickResult).sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
left.stats.leadership +
|
left.stats.leadership +
|
||||||
@@ -289,16 +298,18 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
refresh?: boolean;
|
refresh?: boolean;
|
||||||
keepIds?: number[];
|
keepIds?: number[];
|
||||||
now?: Date;
|
now?: Date;
|
||||||
acceptedGameTick: number;
|
createdGameTick: number;
|
||||||
selectionObserver?: NpcPossessionSelectionObserver;
|
selectionObserver?: NpcPossessionSelectionObserver;
|
||||||
}): Promise<NpcPossessionReservation> => {
|
}): Promise<NpcPossessionReservation> => {
|
||||||
const { db, worldState, userId } = options;
|
const { db, worldState, userId } = options;
|
||||||
requireNpcPossessionWorld(worldState);
|
requireNpcPossessionWorld(worldState);
|
||||||
const now = truncateToSeconds(options.now ?? new Date());
|
const now = truncateToSeconds(options.now ?? new Date());
|
||||||
if (!Number.isSafeInteger(options.acceptedGameTick)) {
|
if (!Number.isSafeInteger(options.createdGameTick)) {
|
||||||
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.');
|
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 생성 tick이 올바르지 않습니다.');
|
||||||
}
|
}
|
||||||
await lockNpcPossession(db, userId);
|
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 } })) {
|
if (await db.general.findFirst({ where: { userId }, select: { id: true } })) {
|
||||||
fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다');
|
fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다');
|
||||||
@@ -324,14 +335,20 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
if (options.refresh) {
|
if (options.refresh) {
|
||||||
fail('CONFLICT', 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.');
|
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({
|
await db.npcSelectionToken.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
nonce: existing.nonce,
|
nonce: existing.nonce,
|
||||||
validUntil: { lt: now },
|
OR: [
|
||||||
|
{ validUntilTick: null },
|
||||||
|
{ validUntilTick: { lt: BigInt(options.createdGameTick) } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
existing = null;
|
existing = null;
|
||||||
@@ -339,7 +356,7 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
|
|
||||||
const kept: Record<string, NpcPossessionCandidate> = {};
|
const kept: Record<string, NpcPossessionCandidate> = {};
|
||||||
if (existing && options.refresh) {
|
if (existing && options.refresh) {
|
||||||
if (now.getTime() < existing.pickMoreFrom.getTime()) {
|
if (existing.pickMoreFromTick === null || options.createdGameTick < Number(existing.pickMoreFromTick)) {
|
||||||
fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다');
|
fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다');
|
||||||
}
|
}
|
||||||
const oldPick = parsePickResult(existing.pickResult);
|
const oldPick = parsePickResult(existing.pickResult);
|
||||||
@@ -352,16 +369,16 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
}
|
}
|
||||||
// Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다.
|
// Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다.
|
||||||
if (Object.keys(kept).length === Object.keys(oldPick).length) {
|
if (Object.keys(kept).length === Object.keys(oldPick).length) {
|
||||||
return toReservation(existing, now);
|
return toReservation(existing, options.createdGameTick, ticksPerSecond);
|
||||||
}
|
}
|
||||||
} else if (existing) {
|
} else if (existing) {
|
||||||
return toReservation(existing, now);
|
return toReservation(existing, options.createdGameTick, ticksPerSecond);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reservedRows = await db.npcSelectionToken.findMany({
|
const reservedRows = await db.npcSelectionToken.findMany({
|
||||||
where: {
|
where: {
|
||||||
ownerUserId: { not: userId },
|
ownerUserId: { not: userId },
|
||||||
validUntil: { gte: now },
|
validUntilTick: { gte: BigInt(options.createdGameTick) },
|
||||||
},
|
},
|
||||||
select: { pickResult: true },
|
select: { pickResult: true },
|
||||||
});
|
});
|
||||||
@@ -397,16 +414,19 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
|
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
|
||||||
);
|
);
|
||||||
const selectionRng = new LiteHashDRBG(
|
const selectionRng = new LiteHashDRBG(
|
||||||
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick)
|
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.createdGameTick)
|
||||||
);
|
);
|
||||||
const rng = options.selectionObserver?.onRandomDraw
|
const rng = options.selectionObserver?.onRandomDraw
|
||||||
? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw)
|
? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw)
|
||||||
: new RandUtil(selectionRng);
|
: new RandUtil(selectionRng);
|
||||||
const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw);
|
const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw);
|
||||||
const turnTermMinutes = resolveTurnTermMinutes(worldState);
|
const validSeconds = Math.max(VALID_SECONDS, turnTermMinutes * 40);
|
||||||
const validUntil = new Date(now.getTime() + Math.max(VALID_SECONDS, turnTermMinutes * 40) * 1000);
|
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(
|
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);
|
const nonce = randomInt(0, 0x10000000);
|
||||||
|
|
||||||
@@ -415,7 +435,9 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
where: { ownerUserId: userId, nonce: existing.nonce },
|
where: { ownerUserId: userId, nonce: existing.nonce },
|
||||||
data: {
|
data: {
|
||||||
validUntil,
|
validUntil,
|
||||||
|
validUntilTick: BigInt(validUntilTick),
|
||||||
pickMoreFrom: refreshedPickMoreFrom,
|
pickMoreFrom: refreshedPickMoreFrom,
|
||||||
|
pickMoreFromTick: BigInt(pickMoreFromTick),
|
||||||
pickResult: pickResult as GamePrisma.InputJsonValue,
|
pickResult: pickResult as GamePrisma.InputJsonValue,
|
||||||
nonce,
|
nonce,
|
||||||
},
|
},
|
||||||
@@ -423,7 +445,18 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
if (updated.count === 0) {
|
if (updated.count === 0) {
|
||||||
fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요');
|
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 {
|
try {
|
||||||
@@ -431,7 +464,9 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
data: {
|
data: {
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
validUntil,
|
validUntil,
|
||||||
|
validUntilTick: BigInt(validUntilTick),
|
||||||
pickMoreFrom: FIRST_PICK_MORE_FROM,
|
pickMoreFrom: FIRST_PICK_MORE_FROM,
|
||||||
|
pickMoreFromTick: BigInt(options.createdGameTick),
|
||||||
pickResult: pickResult as GamePrisma.InputJsonValue,
|
pickResult: pickResult as GamePrisma.InputJsonValue,
|
||||||
nonce,
|
nonce,
|
||||||
},
|
},
|
||||||
@@ -442,7 +477,18 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
}
|
}
|
||||||
throw error;
|
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: {
|
export const possessNpcGeneral = async (options: {
|
||||||
@@ -455,11 +501,13 @@ export const possessNpcGeneral = async (options: {
|
|||||||
ownerLegacyPenalty?: Record<string, unknown>;
|
ownerLegacyPenalty?: Record<string, unknown>;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
tokenNonce: number;
|
tokenNonce: number;
|
||||||
acceptedAt: Date;
|
requestedAtWall: Date;
|
||||||
|
processingGameTick: number;
|
||||||
}): Promise<{ ok: true; generalId: number }> => {
|
}): Promise<{ ok: true; generalId: number }> => {
|
||||||
const { db, world, worldState, userId, generalId, acceptedAt } = options;
|
const { db, world, worldState, userId, generalId, requestedAtWall } = options;
|
||||||
// queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다.
|
if (!Number.isSafeInteger(options.processingGameTick)) {
|
||||||
const tokenAcceptedAt = truncateToSeconds(acceptedAt);
|
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 처리 tick이 올바르지 않습니다.');
|
||||||
|
}
|
||||||
requireNpcPossessionWorld(worldState);
|
requireNpcPossessionWorld(worldState);
|
||||||
await lockNpcPossession(db, userId);
|
await lockNpcPossession(db, userId);
|
||||||
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
|
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
|
||||||
@@ -475,7 +523,7 @@ export const possessNpcGeneral = async (options: {
|
|||||||
where: {
|
where: {
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
nonce: options.tokenNonce,
|
nonce: options.tokenNonce,
|
||||||
validUntil: { gte: tokenAcceptedAt },
|
validUntilTick: { gte: BigInt(options.processingGameTick) },
|
||||||
},
|
},
|
||||||
})) as NpcSelectionTokenRow | null;
|
})) as NpcSelectionTokenRow | null;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -501,7 +549,7 @@ export const possessNpcGeneral = async (options: {
|
|||||||
return fail('NOT_FOUND', '장수 등록에 실패했습니다.');
|
return fail('NOT_FOUND', '장수 등록에 실패했습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, acceptedAt);
|
const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, requestedAtWall);
|
||||||
world.updateGeneral(generalId, {
|
world.updateGeneral(generalId, {
|
||||||
userId,
|
userId,
|
||||||
npcState: 1,
|
npcState: 1,
|
||||||
@@ -522,7 +570,7 @@ export const possessNpcGeneral = async (options: {
|
|||||||
where: { generalId },
|
where: { generalId },
|
||||||
update: {
|
update: {
|
||||||
userId,
|
userId,
|
||||||
lastRefresh: acceptedAt,
|
lastRefresh: requestedAtWall,
|
||||||
refresh: 0,
|
refresh: 0,
|
||||||
refreshTotal: 0,
|
refreshTotal: 0,
|
||||||
refreshScore: 0,
|
refreshScore: 0,
|
||||||
@@ -531,7 +579,7 @@ export const possessNpcGeneral = async (options: {
|
|||||||
create: {
|
create: {
|
||||||
generalId,
|
generalId,
|
||||||
userId,
|
userId,
|
||||||
lastRefresh: acceptedAt,
|
lastRefresh: requestedAtWall,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId } });
|
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 { isRecord } from '@sammo-ts/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
@@ -75,6 +80,7 @@ const buildTurnListFromRows = (
|
|||||||
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
|
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
|
||||||
|
|
||||||
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & {
|
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'> & {
|
||||||
|
$queryRaw?<T>(query: GamePrisma.Sql): Promise<T>;
|
||||||
generalTurnRevision?: Pick<
|
generalTurnRevision?: Pick<
|
||||||
NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>,
|
NonNullable<TurnEngineDatabaseClient['generalTurnRevision']>,
|
||||||
'findUnique' | 'createMany' | 'updateMany'
|
'findUnique' | 'createMany' | 'updateMany'
|
||||||
@@ -313,8 +319,17 @@ export class InMemoryReservedTurnStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLeaseExpiresAt(): Date {
|
private getLeaseExpiresAt(nowWall: Date): Date {
|
||||||
return new Date(Date.now() + this.leaseDurationMs);
|
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> {
|
private async acquireGeneralLease(generalId: number): Promise<boolean> {
|
||||||
@@ -322,7 +337,7 @@ export class InMemoryReservedTurnStore {
|
|||||||
if (!revisionStore) {
|
if (!revisionStore) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const now = new Date();
|
const now = await this.readDatabaseWallTime();
|
||||||
const previous = (await revisionStore.findUnique({ where: { generalId } })) as {
|
const previous = (await revisionStore.findUnique({ where: { generalId } })) as {
|
||||||
leaseOwner: string | null;
|
leaseOwner: string | null;
|
||||||
leaseExpiresAt: Date | null;
|
leaseExpiresAt: Date | null;
|
||||||
@@ -331,7 +346,7 @@ export class InMemoryReservedTurnStore {
|
|||||||
previous?.leaseOwner === this.leaseOwner &&
|
previous?.leaseOwner === this.leaseOwner &&
|
||||||
previous.leaseExpiresAt !== null &&
|
previous.leaseExpiresAt !== null &&
|
||||||
previous.leaseExpiresAt.getTime() > now.getTime();
|
previous.leaseExpiresAt.getTime() > now.getTime();
|
||||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||||
let claimed = await revisionStore.updateMany({
|
let claimed = await revisionStore.updateMany({
|
||||||
where: {
|
where: {
|
||||||
generalId,
|
generalId,
|
||||||
@@ -372,7 +387,7 @@ export class InMemoryReservedTurnStore {
|
|||||||
if (!revisionStore) {
|
if (!revisionStore) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const now = new Date();
|
const now = await this.readDatabaseWallTime();
|
||||||
const previous = (await revisionStore.findUnique({
|
const previous = (await revisionStore.findUnique({
|
||||||
where: { nationId_officerLevel: { nationId, officerLevel } },
|
where: { nationId_officerLevel: { nationId, officerLevel } },
|
||||||
})) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null;
|
})) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null;
|
||||||
@@ -380,7 +395,7 @@ export class InMemoryReservedTurnStore {
|
|||||||
previous?.leaseOwner === this.leaseOwner &&
|
previous?.leaseOwner === this.leaseOwner &&
|
||||||
previous.leaseExpiresAt !== null &&
|
previous.leaseExpiresAt !== null &&
|
||||||
previous.leaseExpiresAt.getTime() > now.getTime();
|
previous.leaseExpiresAt.getTime() > now.getTime();
|
||||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||||
let claimed = await revisionStore.updateMany({
|
let claimed = await revisionStore.updateMany({
|
||||||
where: {
|
where: {
|
||||||
nationId,
|
nationId,
|
||||||
@@ -651,12 +666,13 @@ export class InMemoryReservedTurnStore {
|
|||||||
if (!revisionStore) {
|
if (!revisionStore) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
const now = await this.readDatabaseWallTime(prisma);
|
||||||
|
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||||
const where = this.leasedGeneralIds.has(generalId)
|
const where = this.leasedGeneralIds.has(generalId)
|
||||||
? { generalId, leaseOwner: this.leaseOwner }
|
? { generalId, leaseOwner: this.leaseOwner }
|
||||||
: {
|
: {
|
||||||
generalId,
|
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({
|
let claimed = await revisionStore.updateMany({
|
||||||
where,
|
where,
|
||||||
@@ -721,13 +737,14 @@ export class InMemoryReservedTurnStore {
|
|||||||
if (!revisionStore) {
|
if (!revisionStore) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const leaseExpiresAt = this.getLeaseExpiresAt();
|
const now = await this.readDatabaseWallTime(prisma);
|
||||||
|
const leaseExpiresAt = this.getLeaseExpiresAt(now);
|
||||||
const where = this.leasedNationKeys.has(key)
|
const where = this.leasedNationKeys.has(key)
|
||||||
? { nationId, officerLevel, leaseOwner: this.leaseOwner }
|
? { nationId, officerLevel, leaseOwner: this.leaseOwner }
|
||||||
: {
|
: {
|
||||||
nationId,
|
nationId,
|
||||||
officerLevel,
|
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({
|
let claimed = await revisionStore.updateMany({
|
||||||
where,
|
where,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { readInputEventClockCoordinate, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildGameEventChannel,
|
buildGameEventChannel,
|
||||||
@@ -89,8 +90,8 @@ const shiftTournamentClock = async (
|
|||||||
};
|
};
|
||||||
const lockKey = `${stateKey}:mutation-lock`;
|
const lockKey = `${stateKey}:mutation-lock`;
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const deadline = Date.now() + 2_000;
|
const deadline = performance.now() + 2_000;
|
||||||
while (Date.now() < deadline) {
|
while (performance.now() < deadline) {
|
||||||
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||||
if (acquired) {
|
if (acquired) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildGameEventChannel,
|
buildGameEventChannel,
|
||||||
@@ -158,8 +159,8 @@ const reprojectTournamentClock = async (
|
|||||||
};
|
};
|
||||||
const lockKey = `${stateKey}:mutation-lock`;
|
const lockKey = `${stateKey}:mutation-lock`;
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const deadline = Date.now() + 2_000;
|
const deadline = performance.now() + 2_000;
|
||||||
while (Date.now() < deadline) {
|
while (performance.now() < deadline) {
|
||||||
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
const acquired = await redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||||
if (acquired) {
|
if (acquired) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -268,13 +268,10 @@ const toReservationDto = (
|
|||||||
world: InMemoryTurnWorld
|
world: InMemoryTurnWorld
|
||||||
): Promise<SelectPoolReservationDto> => {
|
): Promise<SelectPoolReservationDto> => {
|
||||||
const first = rows[0];
|
const first = rows[0];
|
||||||
if (!first || (first.reservedUntilTick === null && first.reservedUntil === null)) {
|
if (!first || first.reservedUntilTick === null) {
|
||||||
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
|
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
|
||||||
}
|
}
|
||||||
const expiresAt =
|
const expiresAt = world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
|
||||||
first.reservedUntilTick === null
|
|
||||||
? first.reservedUntil!
|
|
||||||
: world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
|
|
||||||
const poolName = resolvePoolName(worldState);
|
const poolName = resolvePoolName(worldState);
|
||||||
if (!poolName || !SUPPORTED_POOLS.has(poolName)) {
|
if (!poolName || !SUPPORTED_POOLS.has(poolName)) {
|
||||||
throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
|
throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
|
||||||
@@ -355,6 +352,23 @@ const readNextChangeAt = (generalMeta: unknown): Date | null => {
|
|||||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
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 getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||||
const meta = asRecord(worldState.meta);
|
const meta = asRecord(worldState.meta);
|
||||||
const value = meta.hiddenSeed ?? meta.seed;
|
const value = meta.hiddenSeed ?? meta.seed;
|
||||||
@@ -371,18 +385,8 @@ const toSafeReservationTick = (value: bigint | number, uniqueName: string): numb
|
|||||||
return tick;
|
return tick;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveAcceptedGameTick = (world: InMemoryTurnWorld, now: Date): number => {
|
const isReservationActive = (row: SelectPoolRow, nowTick: number): boolean =>
|
||||||
const tick = world.dateToGameTick(now);
|
row.reservedUntilTick !== null && toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick;
|
||||||
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 lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
|
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||||
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
|
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
|
||||||
@@ -392,7 +396,6 @@ const requireSelectionToken = async (
|
|||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
userId: string,
|
userId: string,
|
||||||
uniqueName: string,
|
uniqueName: string,
|
||||||
now: Date,
|
|
||||||
nowTick: number
|
nowTick: number
|
||||||
): Promise<SelectPoolRow> => {
|
): Promise<SelectPoolRow> => {
|
||||||
const token = await db.selectPoolEntry.findFirst({
|
const token = await db.selectPoolEntry.findFirst({
|
||||||
@@ -400,10 +403,7 @@ const requireSelectionToken = async (
|
|||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
uniqueName,
|
uniqueName,
|
||||||
generalId: null,
|
generalId: null,
|
||||||
OR: [
|
reservedUntilTick: { gte: BigInt(nowTick) },
|
||||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
|
||||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -438,18 +438,13 @@ export const reserveSelectionPool = async (options: {
|
|||||||
worldState: WorldStateRow;
|
worldState: WorldStateRow;
|
||||||
userId: string;
|
userId: string;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
acceptedGameTick?: number;
|
processingGameTick: number;
|
||||||
processingGameTick?: number;
|
|
||||||
seedOwnerIdentity?: string | number;
|
seedOwnerIdentity?: string | number;
|
||||||
}): Promise<SelectPoolReservationDto> => {
|
}): Promise<SelectPoolReservationDto> => {
|
||||||
const { db, world, worldState, userId } = options;
|
const { db, world, worldState, userId } = options;
|
||||||
requirePoolWorld(worldState);
|
requirePoolWorld(worldState);
|
||||||
const now = options.now ?? new Date();
|
const now = options.now ?? new Date();
|
||||||
const acceptedGameTick = options.acceptedGameTick ?? resolveAcceptedGameTick(world, now);
|
const processingGameTick = options.processingGameTick;
|
||||||
const processingGameTick = options.processingGameTick ?? acceptedGameTick;
|
|
||||||
if (!Number.isSafeInteger(acceptedGameTick)) {
|
|
||||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
|
|
||||||
}
|
|
||||||
if (!Number.isSafeInteger(processingGameTick)) {
|
if (!Number.isSafeInteger(processingGameTick)) {
|
||||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
|
fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||||
}
|
}
|
||||||
@@ -459,14 +454,11 @@ export const reserveSelectionPool = async (options: {
|
|||||||
where: { userId },
|
where: { userId },
|
||||||
select: { id: true, meta: true },
|
select: { id: true, meta: true },
|
||||||
});
|
});
|
||||||
const nextChangeAt = general ? readNextChangeAt(general.meta) : null;
|
if (general) assertReselectionCooldown(general.meta, processingGameTick);
|
||||||
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
|
|
||||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentRows = await synchronizeSelectionPoolWorld(db, world);
|
let currentRows = await synchronizeSelectionPoolWorld(db, world);
|
||||||
const existing = currentRows.filter(
|
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) {
|
if (existing.length > 0) {
|
||||||
return toReservationDto(existing, Boolean(general), worldState, world);
|
return toReservationDto(existing, Boolean(general), worldState, world);
|
||||||
@@ -477,7 +469,7 @@ export const reserveSelectionPool = async (options: {
|
|||||||
generalId: null,
|
generalId: null,
|
||||||
OR: [
|
OR: [
|
||||||
{ reservedUntilTick: { lt: BigInt(processingGameTick) } },
|
{ reservedUntilTick: { lt: BigInt(processingGameTick) } },
|
||||||
{ reservedUntilTick: null, reservedUntil: { lt: now } },
|
{ reservedUntilTick: null, reservedUntil: { not: null } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
@@ -504,7 +496,7 @@ export const reserveSelectionPool = async (options: {
|
|||||||
|
|
||||||
const rng = new RandUtil(
|
const rng = new RandUtil(
|
||||||
new LiteHashDRBG(
|
new LiteHashDRBG(
|
||||||
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, acceptedGameTick)
|
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, processingGameTick)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const poolName = resolvePoolName(worldState)!;
|
const poolName = resolvePoolName(worldState)!;
|
||||||
@@ -578,7 +570,6 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor
|
|||||||
const clearUnusedReservations = async (
|
const clearUnusedReservations = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
userId: string,
|
userId: string,
|
||||||
now: Date,
|
|
||||||
nowTick: number
|
nowTick: number
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await db.selectPoolEntry.updateMany({
|
await db.selectPoolEntry.updateMany({
|
||||||
@@ -587,7 +578,7 @@ const clearUnusedReservations = async (
|
|||||||
OR: [
|
OR: [
|
||||||
{ ownerUserId: userId },
|
{ ownerUserId: userId },
|
||||||
{ reservedUntilTick: { lt: BigInt(nowTick) } },
|
{ reservedUntilTick: { lt: BigInt(nowTick) } },
|
||||||
{ reservedUntilTick: null, reservedUntil: { lt: now } },
|
{ reservedUntilTick: null, reservedUntil: { not: null } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
@@ -716,6 +707,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
now?: Date;
|
now?: Date;
|
||||||
turnScheduleAt?: Date;
|
turnScheduleAt?: Date;
|
||||||
operationalAcceptedAt: Date;
|
operationalAcceptedAt: Date;
|
||||||
|
processingGameTick: number;
|
||||||
seedOwnerIdentity?: string | number;
|
seedOwnerIdentity?: string | number;
|
||||||
ownerPicture?: string;
|
ownerPicture?: string;
|
||||||
ownerImageServer?: number;
|
ownerImageServer?: number;
|
||||||
@@ -724,7 +716,10 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
||||||
requirePoolWorld(worldState);
|
requirePoolWorld(worldState);
|
||||||
const now = options.now ?? new Date();
|
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 lockSelectionUser(db, userId);
|
||||||
await lockSelectionMutationTables(db);
|
await lockSelectionMutationTables(db);
|
||||||
await synchronizeSelectionPoolWorld(db, world);
|
await synchronizeSelectionPoolWorld(db, world);
|
||||||
@@ -735,7 +730,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
) {
|
) {
|
||||||
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.');
|
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 info = parseCandidate(token);
|
||||||
const poolName = resolvePoolName(worldState)!;
|
const poolName = resolvePoolName(worldState)!;
|
||||||
const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL;
|
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 turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
|
||||||
const age = 20;
|
const age = 20;
|
||||||
const specialityAges = resolveSpecialityAges(worldState, age);
|
const specialityAges = resolveSpecialityAges(worldState, age);
|
||||||
const nextChangeAt = new Date(
|
const nextChangeTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
|
||||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
const nextChangeAt = world.gameTickToDate(nextChangeTick);
|
||||||
);
|
|
||||||
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
|
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
|
||||||
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
|
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
|
||||||
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
|
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
|
||||||
@@ -813,6 +807,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
dex5: isCentennial ? 0 : info.dex[4],
|
dex5: isCentennial ? 0 : info.dex[4],
|
||||||
next_change: nextChangeAt.toISOString(),
|
next_change: nextChangeAt.toISOString(),
|
||||||
nextChangeAt: nextChangeAt.toISOString(),
|
nextChangeAt: nextChangeAt.toISOString(),
|
||||||
|
next_change_tick: nextChangeTick,
|
||||||
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||||
...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}),
|
...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}),
|
||||||
npc_org: 0,
|
npc_org: 0,
|
||||||
@@ -905,10 +900,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
id: token.id,
|
id: token.id,
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
generalId: null,
|
generalId: null,
|
||||||
OR: [
|
reservedUntilTick: { gte: BigInt(nowTick) },
|
||||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
|
||||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
generalId,
|
generalId,
|
||||||
@@ -925,7 +917,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
update: { userId, lastRefresh: options.operationalAcceptedAt },
|
update: { userId, lastRefresh: options.operationalAcceptedAt },
|
||||||
create: { generalId, 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);
|
await synchronizeSelectionPoolWorld(db, world);
|
||||||
|
|
||||||
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
||||||
@@ -949,11 +941,15 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
|||||||
ownerDisplayName: string;
|
ownerDisplayName: string;
|
||||||
uniqueName: string;
|
uniqueName: string;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
|
processingGameTick: number;
|
||||||
}): Promise<{ ok: true; generalId: number }> => {
|
}): Promise<{ ok: true; generalId: number }> => {
|
||||||
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
||||||
requirePoolWorld(worldState);
|
requirePoolWorld(worldState);
|
||||||
const now = options.now ?? new Date();
|
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 lockSelectionUser(db, userId);
|
||||||
await lockSelectionMutationTables(db);
|
await lockSelectionMutationTables(db);
|
||||||
await synchronizeSelectionPoolWorld(db, world);
|
await synchronizeSelectionPoolWorld(db, world);
|
||||||
@@ -968,11 +964,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
|||||||
if (persistedGeneral.id !== general.id) {
|
if (persistedGeneral.id !== general.id) {
|
||||||
fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.');
|
fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.');
|
||||||
}
|
}
|
||||||
const nextChangeAt = readNextChangeAt(general.meta);
|
assertReselectionCooldown(general.meta, nowTick);
|
||||||
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
|
const token = await requireSelectionToken(db, userId, uniqueName, nowTick);
|
||||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
|
||||||
}
|
|
||||||
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
|
|
||||||
const info = parseCandidate(token);
|
const info = parseCandidate(token);
|
||||||
const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL;
|
const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL;
|
||||||
|
|
||||||
@@ -982,10 +975,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
|||||||
id: token.id,
|
id: token.id,
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
generalId: null,
|
generalId: null,
|
||||||
OR: [
|
reservedUntilTick: { gte: BigInt(nowTick) },
|
||||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
|
||||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
generalId: provisionalGeneralId,
|
generalId: provisionalGeneralId,
|
||||||
@@ -1014,9 +1004,8 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
|||||||
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
|
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cooldown = new Date(
|
const cooldownTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
|
||||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
const cooldown = world.gameTickToDate(cooldownTick);
|
||||||
);
|
|
||||||
const centennialBaseGeneral = isCentennial
|
const centennialBaseGeneral = isCentennial
|
||||||
? {
|
? {
|
||||||
...general,
|
...general,
|
||||||
@@ -1046,6 +1035,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
|||||||
: {}),
|
: {}),
|
||||||
next_change: cooldown.toISOString(),
|
next_change: cooldown.toISOString(),
|
||||||
nextChangeAt: cooldown.toISOString(),
|
nextChangeAt: cooldown.toISOString(),
|
||||||
|
next_change_tick: cooldownTick,
|
||||||
...buildScenarioGeneralPoolClaimMeta(
|
...buildScenarioGeneralPoolClaimMeta(
|
||||||
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
|
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
|
||||||
now
|
now
|
||||||
@@ -1074,7 +1064,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
|||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
|
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
|
||||||
}
|
}
|
||||||
await clearUnusedReservations(db, userId, now, nowTick);
|
await clearUnusedReservations(db, userId, nowTick);
|
||||||
await synchronizeSelectionPoolWorld(db, world);
|
await synchronizeSelectionPoolWorld(db, world);
|
||||||
|
|
||||||
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
|
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 type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||||
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
|
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
@@ -119,22 +123,18 @@ interface HighestUnificationBidRow {
|
|||||||
meta: unknown;
|
meta: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: MessageRecordDraft): Promise<number> => {
|
const insertMessage = async (
|
||||||
const rows = await transaction.$queryRaw<Array<{ id: number }>>`
|
transaction: GamePrisma.TransactionClient,
|
||||||
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
|
world: InMemoryTurnWorld,
|
||||||
VALUES (
|
draft: MessageRecordDraft
|
||||||
${draft.mailbox},
|
): Promise<number> => {
|
||||||
${draft.msgType},
|
const clock = world.getGameClockState();
|
||||||
${draft.srcId},
|
const id = await persistMessageEnvelope(transaction, draft, {
|
||||||
${draft.destId},
|
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
|
||||||
${draft.time},
|
clockRevision: BigInt(clock.revision),
|
||||||
${draft.validUntil},
|
deadlineGeneration: BigInt(clock.deadlineGeneration),
|
||||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
expiresGameTick: null,
|
||||||
)
|
});
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
const id = rows[0]?.id;
|
|
||||||
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
|
|
||||||
await enqueuePrivateMessageWebPush(transaction, draft, id);
|
await enqueuePrivateMessageWebPush(transaction, draft, id);
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
@@ -252,7 +252,7 @@ const cancelPendingUniqueAuctions = async (
|
|||||||
await sendMessage(
|
await sendMessage(
|
||||||
{
|
{
|
||||||
insertMessage: async (draft) => {
|
insertMessage: async (draft) => {
|
||||||
const messageId = await insertMessage(transaction, draft);
|
const messageId = await insertMessage(transaction, world, draft);
|
||||||
messageMailboxes.push(draft.mailbox);
|
messageMailboxes.push(draft.mailbox);
|
||||||
return messageId;
|
return messageId;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -278,18 +278,12 @@ const resolveSelectionCommandAcceptedAt = async (
|
|||||||
world: InMemoryTurnWorld,
|
world: InMemoryTurnWorld,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }>
|
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }>
|
||||||
): Promise<Date> => {
|
): Promise<Date> => {
|
||||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
await resolveCommandAcceptedAt(db, command);
|
||||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||||
if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) {
|
if (typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)) {
|
||||||
return world.gameTickToDate(processingGameTick);
|
return world.gameTickToDate(processingGameTick);
|
||||||
}
|
}
|
||||||
if (command.acceptedGameTick !== undefined) {
|
throw new Error(`${command.type} requires an authoritative daemon processing game tick.`);
|
||||||
return world.gameTickToDate(command.acceptedGameTick);
|
|
||||||
}
|
|
||||||
if (command.acceptedGameAt !== undefined) {
|
|
||||||
return new Date(command.acceptedGameAt);
|
|
||||||
}
|
|
||||||
return world.getGameNow(operationalAcceptedAt);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveOperationalAcceptedAt = async (
|
const resolveOperationalAcceptedAt = async (
|
||||||
@@ -417,12 +411,9 @@ async function handleNpcPossessGeneral(
|
|||||||
}
|
}
|
||||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||||
const acceptedAt =
|
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||||
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)
|
throw new Error('npcPossessGeneral requires an authoritative daemon processing game tick.');
|
||||||
? ctx.world.gameTickToDate(processingGameTick)
|
}
|
||||||
: command.acceptedGameAt
|
|
||||||
? new Date(command.acceptedGameAt)
|
|
||||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'npcPossessGeneral',
|
type: 'npcPossessGeneral',
|
||||||
@@ -436,7 +427,8 @@ async function handleNpcPossessGeneral(
|
|||||||
...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}),
|
...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}),
|
||||||
generalId: command.generalId,
|
generalId: command.generalId,
|
||||||
tokenNonce: command.tokenNonce,
|
tokenNonce: command.tokenNonce,
|
||||||
acceptedAt,
|
requestedAtWall: operationalAcceptedAt,
|
||||||
|
processingGameTick,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -465,15 +457,11 @@ async function handleSelectPoolCreate(
|
|||||||
}
|
}
|
||||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||||
const acceptedAt =
|
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||||
typeof processingGameTick === 'number' && Number.isSafeInteger(processingGameTick)
|
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
|
||||||
? ctx.world.gameTickToDate(processingGameTick)
|
}
|
||||||
: command.acceptedGameTick !== undefined
|
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
||||||
? ctx.world.gameTickToDate(command.acceptedGameTick)
|
const turnScheduleAt = acceptedAt;
|
||||||
: command.acceptedGameAt !== undefined
|
|
||||||
? new Date(command.acceptedGameAt)
|
|
||||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
|
||||||
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'selectPoolCreate',
|
type: 'selectPoolCreate',
|
||||||
@@ -492,6 +480,7 @@ async function handleSelectPoolCreate(
|
|||||||
now: acceptedAt,
|
now: acceptedAt,
|
||||||
turnScheduleAt,
|
turnScheduleAt,
|
||||||
operationalAcceptedAt,
|
operationalAcceptedAt,
|
||||||
|
processingGameTick,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -530,10 +519,7 @@ async function handleSelectPoolReserve(
|
|||||||
userId: command.userId,
|
userId: command.userId,
|
||||||
seedOwnerIdentity: command.seedOwnerIdentity,
|
seedOwnerIdentity: command.seedOwnerIdentity,
|
||||||
now: acceptedAt,
|
now: acceptedAt,
|
||||||
...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }),
|
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
|
||||||
...(typeof Reflect.get(command, 'processingGameTick') === 'number'
|
|
||||||
? { processingGameTick: Reflect.get(command, 'processingGameTick') as number }
|
|
||||||
: {}),
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -572,6 +558,7 @@ async function handleSelectPoolReselect(
|
|||||||
ownerDisplayName: command.ownerDisplayName,
|
ownerDisplayName: command.ownerDisplayName,
|
||||||
uniqueName: command.uniqueName,
|
uniqueName: command.uniqueName,
|
||||||
now: acceptedAt,
|
now: acceptedAt,
|
||||||
|
processingGameTick: Reflect.get(command, 'processingGameTick') as number,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2745,17 +2732,16 @@ type VotePollValidationRow = {
|
|||||||
|
|
||||||
export const hasVotePollDeadlinePassed = (
|
export const hasVotePollDeadlinePassed = (
|
||||||
poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>,
|
poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>,
|
||||||
acceptedGameAt: Date,
|
currentGameTick: number
|
||||||
acceptedGameTick: number
|
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const endTick =
|
const endTick =
|
||||||
poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick);
|
poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick);
|
||||||
return (
|
if (poll.closedAt !== null) return true;
|
||||||
poll.closedAt !== null ||
|
// No projection and no tick means an intentionally unbounded poll. A
|
||||||
(endTick !== null
|
// projection without its authoritative tick is a broken GAME deadline and
|
||||||
? endTick < BigInt(acceptedGameTick)
|
// therefore fails closed.
|
||||||
: Boolean(poll.endAt && poll.endAt.getTime() < acceptedGameAt.getTime()))
|
if (endTick === null) return poll.endAt !== null;
|
||||||
);
|
return endTick < BigInt(currentGameTick);
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseVoteOptionCount = (value: unknown): number => {
|
const parseVoteOptionCount = (value: unknown): number => {
|
||||||
@@ -2790,17 +2776,11 @@ const validateVoteSelectionInTransaction = async (
|
|||||||
const poll = rows[0];
|
const poll = rows[0];
|
||||||
if (!poll) return '설문조사가 없습니다.';
|
if (!poll) return '설문조사가 없습니다.';
|
||||||
|
|
||||||
const processingNow = ctx.world.getGameNow(new Date());
|
|
||||||
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
|
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
|
||||||
const acceptedGameTick =
|
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
|
||||||
typeof convertedProcessingTick === 'number' && Number.isSafeInteger(convertedProcessingTick)
|
throw new Error('voteReward requires an authoritative daemon processing game tick.');
|
||||||
? convertedProcessingTick
|
}
|
||||||
: (command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow));
|
if (hasVotePollDeadlinePassed(poll, convertedProcessingTick)) {
|
||||||
const acceptedGameAt =
|
|
||||||
command.acceptedGameTick === undefined && convertedProcessingTick === undefined
|
|
||||||
? processingNow
|
|
||||||
: ctx.world.gameTickToDate(acceptedGameTick);
|
|
||||||
if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) {
|
|
||||||
return '설문조사가 종료되었습니다.';
|
return '설문조사가 종료되었습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const destination = {
|
|||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
icon: '',
|
icon: '',
|
||||||
};
|
};
|
||||||
|
const requestId = 'actionable-message-request';
|
||||||
|
|
||||||
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
|
const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePayload> = {}) => ({
|
||||||
id: 29,
|
id: 29,
|
||||||
@@ -94,6 +95,10 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
|
|||||||
type: 'private',
|
type: 'private',
|
||||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||||
|
actionType: action,
|
||||||
|
actionStatus: 'PENDING',
|
||||||
|
createdGameTick: 0n,
|
||||||
|
expiresGameTick: null,
|
||||||
message: {
|
message: {
|
||||||
src: source,
|
src: source,
|
||||||
dest: destination,
|
dest: destination,
|
||||||
@@ -106,10 +111,25 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial<MessagePa
|
|||||||
const buildDb = (rows: unknown[][]) => {
|
const buildDb = (rows: unknown[][]) => {
|
||||||
const queryRaw = vi.fn(async () => rows.shift() ?? []);
|
const queryRaw = vi.fn(async () => rows.shift() ?? []);
|
||||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||||
|
const actionUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||||
return {
|
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,
|
queryRaw,
|
||||||
updateMany,
|
updateMany,
|
||||||
|
actionUpdateMany,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +138,22 @@ const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('actionable message response', () => {
|
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 () => {
|
it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => {
|
||||||
const world = buildWorld();
|
const world = buildWorld();
|
||||||
const row = buildRow('scout');
|
const row = buildRow('scout');
|
||||||
@@ -128,6 +164,7 @@ describe('actionable message response', () => {
|
|||||||
db,
|
db,
|
||||||
world,
|
world,
|
||||||
executor,
|
executor,
|
||||||
|
requestId,
|
||||||
userId: actor.userId!,
|
userId: actor.userId!,
|
||||||
generalId: actor.id,
|
generalId: actor.id,
|
||||||
messageId: row.id,
|
messageId: row.id,
|
||||||
@@ -162,6 +199,7 @@ describe('actionable message response', () => {
|
|||||||
db,
|
db,
|
||||||
world,
|
world,
|
||||||
executor: buildExecutor(false),
|
executor: buildExecutor(false),
|
||||||
|
requestId,
|
||||||
userId: actor.userId!,
|
userId: actor.userId!,
|
||||||
generalId: actor.id,
|
generalId: actor.id,
|
||||||
messageId: row.id,
|
messageId: row.id,
|
||||||
@@ -173,14 +211,8 @@ describe('actionable message response', () => {
|
|||||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
expect(world.peekDirtyState().messages).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => {
|
it('treats a legacy truthy used value as an invalid scout letter', async () => {
|
||||||
for (const row of [
|
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
|
||||||
buildRow('scout', { option: { action: 'scout', used: 1 } }),
|
|
||||||
{
|
|
||||||
...buildRow('scout'),
|
|
||||||
validUntil: new Date('0199-12-31T23:59:59.000Z'),
|
|
||||||
},
|
|
||||||
]) {
|
|
||||||
const world = buildWorld();
|
const world = buildWorld();
|
||||||
const { db, updateMany } = buildDb([[row]]);
|
const { db, updateMany } = buildDb([[row]]);
|
||||||
const executor = buildExecutor();
|
const executor = buildExecutor();
|
||||||
@@ -190,6 +222,7 @@ describe('actionable message response', () => {
|
|||||||
db,
|
db,
|
||||||
world,
|
world,
|
||||||
executor,
|
executor,
|
||||||
|
requestId,
|
||||||
userId: actor.userId!,
|
userId: actor.userId!,
|
||||||
generalId: actor.id,
|
generalId: actor.id,
|
||||||
messageId: row.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 () => {
|
it("keeps PHP's special string-zero used value false", async () => {
|
||||||
const world = buildWorld();
|
const world = buildWorld();
|
||||||
const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
|
const row = buildRow('scout', { option: { action: 'scout', used: '0' } });
|
||||||
@@ -212,6 +263,7 @@ describe('actionable message response', () => {
|
|||||||
db,
|
db,
|
||||||
world,
|
world,
|
||||||
executor,
|
executor,
|
||||||
|
requestId,
|
||||||
userId: actor.userId!,
|
userId: actor.userId!,
|
||||||
generalId: actor.id,
|
generalId: actor.id,
|
||||||
messageId: row.id,
|
messageId: row.id,
|
||||||
@@ -233,6 +285,7 @@ describe('actionable message response', () => {
|
|||||||
db,
|
db,
|
||||||
world,
|
world,
|
||||||
executor,
|
executor,
|
||||||
|
requestId,
|
||||||
userId: actor.userId!,
|
userId: actor.userId!,
|
||||||
generalId: actor.id,
|
generalId: actor.id,
|
||||||
messageId: row.id,
|
messageId: row.id,
|
||||||
@@ -252,6 +305,7 @@ describe('actionable message response', () => {
|
|||||||
db,
|
db,
|
||||||
world,
|
world,
|
||||||
executor: buildExecutor(),
|
executor: buildExecutor(),
|
||||||
|
requestId,
|
||||||
userId: actor.userId!,
|
userId: actor.userId!,
|
||||||
generalId: actor.id,
|
generalId: actor.id,
|
||||||
messageId: row.id,
|
messageId: row.id,
|
||||||
@@ -271,6 +325,7 @@ describe('actionable message response', () => {
|
|||||||
db,
|
db,
|
||||||
world,
|
world,
|
||||||
executor: buildExecutor(),
|
executor: buildExecutor(),
|
||||||
|
requestId,
|
||||||
userId: actor.userId!,
|
userId: actor.userId!,
|
||||||
generalId: actor.id,
|
generalId: actor.id,
|
||||||
messageId: row.id,
|
messageId: row.id,
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
|||||||
world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'],
|
world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'],
|
||||||
});
|
});
|
||||||
const amount = finishImmediately ? 500 : 200;
|
const amount = finishImmediately ? 500 : 200;
|
||||||
|
const requestedAtWall = new Date('2026-08-23T00:00:00.000Z');
|
||||||
const result = await auctionBidder.bid(
|
const result = await auctionBidder.bid(
|
||||||
{
|
{
|
||||||
type: 'auctionBid',
|
type: 'auctionBid',
|
||||||
@@ -114,8 +115,9 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
|||||||
auctionId: 31,
|
auctionId: 31,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount,
|
amount,
|
||||||
acceptedGameTick: 100,
|
processingGameTick: 100,
|
||||||
},
|
requestedAtWall,
|
||||||
|
} as any,
|
||||||
commandDb as any
|
commandDb as any
|
||||||
);
|
);
|
||||||
await auctionBidder.close();
|
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 insert = statements.find((query) => query.strings.join(' ').includes('INSERT INTO auction_bid'));
|
||||||
const update = statements.find((query) => query.strings.join(' ').includes('UPDATE auction'));
|
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', () => {
|
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, closeAt, 72_000_000)).toBe(false);
|
||||||
expect(hasAuctionClosePassed(auction, new Date(closeAt.getTime() + 1), 72_000_001)).toBe(true);
|
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);
|
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 closeAt = new Date('0190-02-01T00:00:00.000Z');
|
||||||
const auction = { closeAt, closeTick: 72_000_000n };
|
const auction = { closeAt, closeTick: 72_000_000n };
|
||||||
const world = {
|
const world = {
|
||||||
dateToGameTick: () => 72_000_001,
|
dateToGameTick: () => 72_000_001,
|
||||||
gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)),
|
gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)),
|
||||||
};
|
};
|
||||||
const processingNow = new Date(closeAt.getTime() + 1);
|
expect(hasAuctionBidClosePassed(auction, world, 72_000_000)).toBe(false);
|
||||||
|
expect(hasAuctionBidClosePassed(auction, world, 72_000_001)).toBe(true);
|
||||||
expect(hasAuctionBidClosePassed(auction, world, processingNow, 72_000_000)).toBe(false);
|
|
||||||
expect(hasAuctionBidClosePassed(auction, world, processingNow)).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
normalizeTurnDaemonCommand({
|
normalizeTurnDaemonCommand({
|
||||||
requestId: 'auction-bid-accepted-tick',
|
requestId: 'auction-bid-accepted-tick',
|
||||||
@@ -161,23 +161,26 @@ describe('resource auction Ref compatibility', () => {
|
|||||||
generalId: 7,
|
generalId: 7,
|
||||||
amount: 500,
|
amount: 500,
|
||||||
acceptedGameTick: 72_000_000,
|
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 () => {
|
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(result).toMatchObject({ type: 'auctionBid', ok: true });
|
||||||
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
|
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
|
||||||
acceptedAt.getTime() + 100_000
|
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([
|
expect(update?.values.filter((value): value is Date => value instanceof Date)).toEqual([
|
||||||
new Date(acceptedAt.getTime() + 100_000),
|
new Date(acceptedAt.getTime() + 100_000),
|
||||||
acceptedAt,
|
acceptedAt,
|
||||||
acceptedAt,
|
|
||||||
]);
|
]);
|
||||||
expect(update?.values).not.toContain(processingAt);
|
expect(update?.values).not.toContain(processingAt);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ import {
|
|||||||
import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js';
|
import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js';
|
||||||
import type { TurnGeneral } from '../src/turn/types.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', () => {
|
describe('unique auction inheritance log compatibility', () => {
|
||||||
it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => {
|
it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => {
|
||||||
const userId = '4c2f2f6d-8a37-4f22-a4f9-1a6f5e4c22ec';
|
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'),
|
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||||
dateToGameTick: (date: Date) => Math.floor(date.getTime() / 1_000),
|
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),
|
updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
|
||||||
pushLog: () => {},
|
pushLog: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await openAuction(
|
const result = await openAuction(
|
||||||
{
|
withDaemonBoundary({
|
||||||
type: 'auctionOpen',
|
type: 'auctionOpen',
|
||||||
userId: 'user-7',
|
userId: 'user-7',
|
||||||
auctionType: 'UNIQUE_ITEM',
|
auctionType: 'UNIQUE_ITEM',
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: 6_000,
|
amount: 6_000,
|
||||||
itemKey: 'che_무기_12_칠성검',
|
itemKey: 'che_무기_12_칠성검',
|
||||||
},
|
}),
|
||||||
world as unknown as Parameters<typeof openAuction>[1],
|
world as unknown as Parameters<typeof openAuction>[1],
|
||||||
db as unknown as NonNullable<Parameters<typeof openAuction>[2]>
|
db as unknown as NonNullable<Parameters<typeof openAuction>[2]>
|
||||||
);
|
);
|
||||||
@@ -192,6 +199,7 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
const world = {
|
const world = {
|
||||||
getGameNow: () => closeAt,
|
getGameNow: () => closeAt,
|
||||||
dateToGameTick: () => 72_000_000,
|
dateToGameTick: () => 72_000_000,
|
||||||
|
gameTickToDate: () => closeAt,
|
||||||
pushLog: vi.fn(),
|
pushLog: vi.fn(),
|
||||||
};
|
};
|
||||||
const finalizer = await createAuctionFinalizer({
|
const finalizer = await createAuctionFinalizer({
|
||||||
@@ -201,12 +209,12 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
finalizer.finalize(
|
finalizer.finalize(
|
||||||
{
|
withDaemonBoundary({
|
||||||
type: 'auctionFinalize',
|
type: 'auctionFinalize',
|
||||||
auctionId: 31,
|
auctionId: 31,
|
||||||
expectedCloseAt: closeAt.toISOString(),
|
expectedCloseAt: closeAt.toISOString(),
|
||||||
expectedCloseTick: 72_000_000,
|
expectedCloseTick: 72_000_000,
|
||||||
},
|
}),
|
||||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||||
)
|
)
|
||||||
).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 });
|
).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 });
|
||||||
@@ -241,6 +249,7 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
const world = {
|
const world = {
|
||||||
getGameNow: () => closeAt,
|
getGameNow: () => closeAt,
|
||||||
dateToGameTick: () => nowTick,
|
dateToGameTick: () => nowTick,
|
||||||
|
gameTickToDate: () => closeAt,
|
||||||
};
|
};
|
||||||
const finalizer = await createAuctionFinalizer({
|
const finalizer = await createAuctionFinalizer({
|
||||||
databaseUrl: 'postgresql://unused',
|
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]>;
|
const db = commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>;
|
||||||
|
|
||||||
await expect(
|
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: '경매 마감 시각이 아직 지나지 않았습니다.' });
|
).resolves.toMatchObject({ ok: false, reason: '경매 마감 시각이 아직 지나지 않았습니다.' });
|
||||||
nowTick = 72_000_000;
|
nowTick = 72_000_000;
|
||||||
await expect(
|
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: '경매 마감 세대가 변경되었습니다.' });
|
).resolves.toMatchObject({ ok: false, reason: '경매 마감 세대가 변경되었습니다.' });
|
||||||
expect(executeRaw).not.toHaveBeenCalled();
|
expect(executeRaw).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -276,12 +297,16 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
detail: { amount: 100 },
|
detail: { amount: 100 },
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt,
|
closeAt,
|
||||||
closeTick: null,
|
closeTick: 72_000_000n,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
const commandDb = { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 0) };
|
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({
|
const finalizer = await createAuctionFinalizer({
|
||||||
databaseUrl: 'postgresql://unused',
|
databaseUrl: 'postgresql://unused',
|
||||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||||
@@ -289,7 +314,12 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
finalizer.finalize(
|
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]>
|
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||||
)
|
)
|
||||||
).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31');
|
).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31');
|
||||||
@@ -317,6 +347,7 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
const queueMessage = vi.fn();
|
const queueMessage = vi.fn();
|
||||||
const world = {
|
const world = {
|
||||||
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
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),
|
getGeneralById: (id: number) => (id === bidder.id ? bidder : id === host.id ? host : null),
|
||||||
getNationById: () => ({ name: '촉', color: '#ff0000' }),
|
getNationById: () => ({ name: '촉', color: '#ff0000' }),
|
||||||
updateGeneral,
|
updateGeneral,
|
||||||
@@ -350,7 +381,7 @@ describe('unique auction inheritance log compatibility', () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
finalizer.finalize(
|
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]>
|
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||||
)
|
)
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
revision: 1,
|
revision: 1,
|
||||||
});
|
});
|
||||||
const generalTicks = [initialTick + 1_234, initialTick + 36_000_123];
|
const generalTicks = [initialTick + 1_234, initialTick + 36_000_123];
|
||||||
|
const reselectionTick = initialTick + 54_000_456;
|
||||||
const auctionCloseTick = initialTick + 72_000_777;
|
const auctionCloseTick = initialTick + 72_000_777;
|
||||||
const messageOccurrenceTick = initialTick - 500;
|
const messageOccurrenceTick = initialTick - 500;
|
||||||
const messageExpiryTick = initialTick + 90_000_999;
|
const messageExpiryTick = initialTick + 90_000_999;
|
||||||
@@ -116,6 +117,14 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
turnTime: clock.tickToDate(turnTick),
|
turnTime: clock.tickToDate(turnTick),
|
||||||
recentWarTick: BigInt(initialTick - 100 - index),
|
recentWarTick: BigInt(initialTick - 100 - index),
|
||||||
recentWarTime: clock.tickToDate(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({
|
await db.auction.create({
|
||||||
@@ -138,7 +147,20 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
timeTick: BigInt(messageOccurrenceTick),
|
timeTick: BigInt(messageOccurrenceTick),
|
||||||
validUntil: clock.tickToDate(messageExpiryTick),
|
validUntil: clock.tickToDate(messageExpiryTick),
|
||||||
validUntilTick: BigInt(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: {},
|
message: {},
|
||||||
|
action: {
|
||||||
|
create: {
|
||||||
|
actionType: 'scout',
|
||||||
|
status: 'PENDING',
|
||||||
|
createdGameTick: BigInt(messageOccurrenceTick),
|
||||||
|
expiresGameTick: BigInt(messageExpiryTick),
|
||||||
|
clockRevision: 1n,
|
||||||
|
deadlineGeneration: 7n,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db.votePoll.create({
|
await db.votePoll.create({
|
||||||
@@ -201,11 +223,13 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
alignedTick: 236_035_000,
|
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.worldState.findUniqueOrThrow({ where: { id: world.id } }),
|
||||||
db.general.findMany({ orderBy: { id: 'asc' } }),
|
db.general.findMany({ orderBy: { id: 'asc' } }),
|
||||||
db.auction.findFirstOrThrow(),
|
db.auction.findFirstOrThrow(),
|
||||||
db.message.findFirstOrThrow(),
|
db.message.findFirstOrThrow(),
|
||||||
|
db.messageAction.findFirstOrThrow(),
|
||||||
db.votePoll.findFirstOrThrow(),
|
db.votePoll.findFirstOrThrow(),
|
||||||
db.selectPoolEntry.findFirstOrThrow(),
|
db.selectPoolEntry.findFirstOrThrow(),
|
||||||
db.npcSelectionToken.findFirstOrThrow(),
|
db.npcSelectionToken.findFirstOrThrow(),
|
||||||
@@ -223,8 +247,19 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
expect(generals.map((general) => general.turnTick! - alignedTick)).toEqual(
|
expect(generals.map((general) => general.turnTick! - alignedTick)).toEqual(
|
||||||
generalTicks.map((tick) => BigInt(tick - initialTick))
|
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(auction.closeTick! - alignedTick).toBe(BigInt(auctionCloseTick - initialTick));
|
||||||
expect(message.validUntilTick! - alignedTick).toBe(BigInt(messageExpiryTick - 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(vote.endTick! - alignedTick).toBe(BigInt(voteEndTick - initialTick));
|
||||||
expect(pool.reservedUntilTick! - alignedTick).toBe(BigInt(poolTick - initialTick));
|
expect(pool.reservedUntilTick! - alignedTick).toBe(BigInt(poolTick - initialTick));
|
||||||
expect(token.validUntilTick! - alignedTick).toBe(BigInt(npcValidTick - initialTick));
|
expect(token.validUntilTick! - alignedTick).toBe(BigInt(npcValidTick - initialTick));
|
||||||
@@ -302,6 +337,21 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
await db.general.create({
|
await db.general.create({
|
||||||
data: { id: 1, name: 'day-general', turnTick: BigInt(turnTick), turnTime: clock.tickToDate(turnTick) },
|
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({
|
await db.turnDaemonLease.create({
|
||||||
data: {
|
data: {
|
||||||
profile: 'clock-day-test',
|
profile: 'clock-day-test',
|
||||||
@@ -346,6 +396,10 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
});
|
});
|
||||||
const shifted = await db.general.findUniqueOrThrow({ where: { id: 1 } });
|
const shifted = await db.general.findUniqueOrThrow({ where: { id: 1 } });
|
||||||
expect(shifted.turnTick! - BigInt(reconciled.alignedTick)).toBe(BigInt(turnTick - initialTick));
|
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');
|
await redis.client.set('sammo:clock-day-test:clock:active-revision', '3');
|
||||||
const redisThenCrash = {
|
const redisThenCrash = {
|
||||||
|
|||||||
@@ -25,7 +25,26 @@ integration('database command queue', () => {
|
|||||||
});
|
});
|
||||||
await db.message.deleteMany({ where: { mailbox: 991_199 } });
|
await db.message.deleteMany({ where: { mailbox: 991_199 } });
|
||||||
await db.worldState.deleteMany({
|
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 () => {
|
afterAll(async () => {
|
||||||
await cleanupFixtures();
|
await cleanupFixtures();
|
||||||
@@ -63,7 +85,16 @@ integration('database command queue', () => {
|
|||||||
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
|
const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]);
|
||||||
const commands = firstCommands.concat(secondCommands);
|
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 });
|
await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 });
|
||||||
|
|
||||||
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||||
@@ -118,7 +149,16 @@ integration('database command queue', () => {
|
|||||||
await queue.initialize();
|
await queue.initialize();
|
||||||
const commands = await queue.drain();
|
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({
|
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({
|
||||||
status: 'PROCESSING',
|
status: 'PROCESSING',
|
||||||
lockedBy: 'active-worker',
|
lockedBy: 'active-worker',
|
||||||
@@ -146,7 +186,14 @@ integration('database command queue', () => {
|
|||||||
const stale = new DatabaseTurnDaemonCommandQueue(db);
|
const stale = new DatabaseTurnDaemonCommandQueue(db);
|
||||||
for (const attempt of [1, 2, 3]) {
|
for (const attempt of [1, 2, 3]) {
|
||||||
await expect(owner.drain()).resolves.toEqual([
|
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 stale.publishCommandError(requestId, new Error('stale worker failure'));
|
||||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||||
@@ -220,7 +267,13 @@ integration('database command queue', () => {
|
|||||||
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
const owner = new DatabaseTurnDaemonCommandQueue(db);
|
||||||
|
|
||||||
const claimed = await owner.drain();
|
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 }));
|
const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction }));
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
type: 'commandRejected',
|
type: 'commandRejected',
|
||||||
@@ -301,7 +354,14 @@ integration('database command queue', () => {
|
|||||||
});
|
});
|
||||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
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({
|
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
processingClockRevision: null,
|
processingClockRevision: null,
|
||||||
@@ -309,7 +369,14 @@ integration('database command queue', () => {
|
|||||||
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } });
|
await db.worldState.update({ where: { id: world.id }, data: { clockPhase: 'RUNNING' } });
|
||||||
|
|
||||||
expect(await queue.drain()).toEqual([
|
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({
|
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayId } })).toMatchObject({
|
||||||
status: 'PROCESSING',
|
status: 'PROCESSING',
|
||||||
@@ -347,6 +414,7 @@ integration('database command queue', () => {
|
|||||||
userId: 'user-8',
|
userId: 'user-8',
|
||||||
generalId: 8,
|
generalId: 8,
|
||||||
processingGameTick: 123,
|
processingGameTick: 123,
|
||||||
|
requestedAtWall: expect.any(Date),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: staleId } })).toMatchObject({
|
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 () => {
|
it('dequeues only the invader decision while an UNIFICATION_WAIT suspension is active', async () => {
|
||||||
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
||||||
const world = existingWorld
|
const world = existingWorld
|
||||||
@@ -389,6 +554,37 @@ integration('database command queue', () => {
|
|||||||
message: { option: { action: 'raiseInvader', used: false } },
|
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({
|
await db.clockSuspension.create({
|
||||||
data: {
|
data: {
|
||||||
id: 'integration-unification-wait',
|
id: 'integration-unification-wait',
|
||||||
@@ -404,6 +600,7 @@ integration('database command queue', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const messageRequestId = 'integration:engine:unification-message';
|
const messageRequestId = 'integration:engine:unification-message';
|
||||||
|
const scoutRequestId = 'integration:engine:suspended-scout-response';
|
||||||
const gameplayRequestId = 'integration:engine:unification-gameplay';
|
const gameplayRequestId = 'integration:engine:unification-gameplay';
|
||||||
await db.inputEvent.createMany({
|
await db.inputEvent.createMany({
|
||||||
data: [
|
data: [
|
||||||
@@ -424,6 +621,23 @@ integration('database command queue', () => {
|
|||||||
response: true,
|
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,
|
requestId: gameplayRequestId,
|
||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
@@ -451,10 +665,15 @@ integration('database command queue', () => {
|
|||||||
generalId: 991_199,
|
generalId: 991_199,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
response: true,
|
response: true,
|
||||||
|
processingGameTick: 900,
|
||||||
|
requestedAtWall: expect.any(Date),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await expect(
|
await expect(
|
||||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } })
|
db.inputEvent.findUniqueOrThrow({ where: { requestId: gameplayRequestId } })
|
||||||
).resolves.toMatchObject({ status: 'PENDING' });
|
).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;
|
db = connector.prisma;
|
||||||
disconnect = () => connector.disconnect();
|
disconnect = () => connector.disconnect();
|
||||||
await dropFailureConstraints();
|
await dropFailureConstraints();
|
||||||
|
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
||||||
@@ -198,6 +199,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
|||||||
await hooks?.close();
|
await hooks?.close();
|
||||||
if (db) {
|
if (db) {
|
||||||
await dropFailureConstraints();
|
await dropFailureConstraints();
|
||||||
|
await db.inheritanceLedger.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
|
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(
|
await expect(
|
||||||
db.inheritancePoint.findUniqueOrThrow({
|
db.inheritancePoint.findUniqueOrThrow({
|
||||||
where: { userId_key: { userId: actorUserId, key: 'previous' } },
|
where: { userId_key: { userId: actorUserId, key: 'previous' } },
|
||||||
@@ -299,6 +307,9 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
|
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
|
||||||
).resolves.toBe(messageCount);
|
).resolves.toBe(messageCount);
|
||||||
|
await expect(
|
||||||
|
db.inheritanceLedger.count({ where: { requestId: { startsWith: requestPrefix } } })
|
||||||
|
).resolves.toBe(ledgerCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pointCommand = buildCommand('point', {
|
const pointCommand = buildCommand('point', {
|
||||||
@@ -315,7 +326,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
|||||||
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`);
|
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).toMatchObject({ inherit_spent_dyn: 17 });
|
||||||
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff');
|
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(
|
await expect(
|
||||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
|
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
@@ -324,7 +335,7 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
|||||||
});
|
});
|
||||||
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
|
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
|
||||||
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 });
|
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 });
|
const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
|
||||||
await createInputEvent(rankCommand);
|
await createInputEvent(rankCommand);
|
||||||
@@ -335,14 +346,14 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
|||||||
`);
|
`);
|
||||||
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
|
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
|
||||||
expect(world.peekDirtyState().messages).toEqual([]);
|
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 db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
|
||||||
await expect(execute(rankCommand)).resolves.toMatchObject({
|
await expect(execute(rankCommand)).resolves.toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
remainPoint: 8_800,
|
remainPoint: 8_800,
|
||||||
ownerName: '레거시 소유자',
|
ownerName: '레거시 소유자',
|
||||||
});
|
});
|
||||||
await assertStored(8_800, 1_217, 2, 2);
|
await assertStored(8_800, 1_217, 2, 2, 2);
|
||||||
|
|
||||||
const currentLog = await db.inheritanceLog.findFirstOrThrow({
|
const currentLog = await db.inheritanceLog.findFirstOrThrow({
|
||||||
where: { userId: actorUserId },
|
where: { userId: actorUserId },
|
||||||
@@ -358,10 +369,10 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
|||||||
`);
|
`);
|
||||||
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
|
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
|
||||||
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique');
|
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 db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
|
||||||
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
|
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', {
|
const freeStatCommand = buildCommand('free-stat', {
|
||||||
action: 'resetStat',
|
action: 'resetStat',
|
||||||
@@ -372,7 +383,21 @@ integration('inheritance action PostgreSQL atomic persistence', () => {
|
|||||||
});
|
});
|
||||||
await createInputEvent(freeStatCommand);
|
await createInputEvent(freeStatCommand);
|
||||||
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
|
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({
|
const messages = await db.message.findMany({
|
||||||
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
|
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ describe('input event atomicity', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
auctionId: 3,
|
auctionId: 3,
|
||||||
closeAt: '2026-01-01T00:10:00.000Z',
|
closeAt: '2026-01-01T00:10:00.000Z',
|
||||||
|
closeTick: 3_600_000,
|
||||||
};
|
};
|
||||||
let resolveResponse: (() => void) | undefined;
|
let resolveResponse: (() => void) | undefined;
|
||||||
const responded = new Promise<void>((resolve) => {
|
const responded = new Promise<void>((resolve) => {
|
||||||
|
|||||||
@@ -806,6 +806,8 @@ describeDb('scenario database seed', () => {
|
|||||||
amount: 1,
|
amount: 1,
|
||||||
eventId: marker,
|
eventId: marker,
|
||||||
eventAt: new Date('2033-01-01T00:00:00.000Z'),
|
eventAt: new Date('2033-01-01T00:00:00.000Z'),
|
||||||
|
occurredGameTick: 0n,
|
||||||
|
requestedAtWall: new Date('2033-01-01T00:00:00.000Z'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const bettingId = 990_731;
|
const bettingId = 990_731;
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ describe('selection-pool reservation command state', () => {
|
|||||||
const rows = buildRows();
|
const rows = buildRows();
|
||||||
const world = buildWorld(rows);
|
const world = buildWorld(rows);
|
||||||
const db = buildDb(rows);
|
const db = buildDb(rows);
|
||||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
const reserve = (userId: string, processingGameTick: number) =>
|
||||||
reserveSelectionPool({
|
reserveSelectionPool({
|
||||||
db: db as never,
|
db: db as never,
|
||||||
world,
|
world,
|
||||||
@@ -232,7 +232,7 @@ describe('selection-pool reservation command state', () => {
|
|||||||
userId,
|
userId,
|
||||||
seedOwnerIdentity: userId,
|
seedOwnerIdentity: userId,
|
||||||
now: acceptedAt,
|
now: acceptedAt,
|
||||||
acceptedGameTick,
|
processingGameTick,
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = await reserve('first-user', 0);
|
const first = await reserve('first-user', 0);
|
||||||
@@ -267,7 +267,7 @@ describe('selection-pool reservation command state', () => {
|
|||||||
rows[1]!.reservedUntilTick = 0n;
|
rows[1]!.reservedUntilTick = 0n;
|
||||||
const world = buildWorld(rows);
|
const world = buildWorld(rows);
|
||||||
const db = buildDb(rows);
|
const db = buildDb(rows);
|
||||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
const reserve = (userId: string, processingGameTick: number) =>
|
||||||
reserveSelectionPool({
|
reserveSelectionPool({
|
||||||
db: db as never,
|
db: db as never,
|
||||||
world,
|
world,
|
||||||
@@ -275,7 +275,7 @@ describe('selection-pool reservation command state', () => {
|
|||||||
userId,
|
userId,
|
||||||
seedOwnerIdentity: userId,
|
seedOwnerIdentity: userId,
|
||||||
now: acceptedAt,
|
now: acceptedAt,
|
||||||
acceptedGameTick,
|
processingGameTick,
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = await reserve('first-user', 0);
|
const first = await reserve('first-user', 0);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
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 type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js';
|
import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js';
|
||||||
|
|
||||||
@@ -81,38 +80,12 @@ const buildDefaultUniquePoolSnapshot = (general: TurnGeneral): TurnWorldSnapshot
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('voteReward command', () => {
|
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');
|
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 }, 0)).toBe(true);
|
||||||
expect(
|
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 0)).toBe(false);
|
||||||
hasVotePollDeadlinePassed(
|
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: 0n, closedAt: null }, 1)).toBe(true);
|
||||||
{ 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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies gold, unique item, logs, and idempotency', async () => {
|
it('applies gold, unique item, logs, and idempotency', async () => {
|
||||||
@@ -290,9 +263,7 @@ describe('voteReward command', () => {
|
|||||||
voteId: 1,
|
voteId: 1,
|
||||||
generalId: 1,
|
generalId: 1,
|
||||||
selection: [0],
|
selection: [0],
|
||||||
// Ref accepts the request at exact equality. Engine processing may
|
processingGameTick: 0,
|
||||||
// occur after the logical clock has advanced beyond the deadline.
|
|
||||||
acceptedGameTick: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const writerWindowStart = Date.now();
|
const writerWindowStart = Date.now();
|
||||||
@@ -418,8 +389,9 @@ describe('voteReward command', () => {
|
|||||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||||
);
|
);
|
||||||
const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld });
|
const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld });
|
||||||
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command;
|
const { processingGameTick: _processingGameTick, ...missingBoundaryCommand } = command;
|
||||||
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, {
|
await expect(
|
||||||
|
legacyLateHandler.handle(missingBoundaryCommand, {
|
||||||
db: {
|
db: {
|
||||||
...actorBindingDb(),
|
...actorBindingDb(),
|
||||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||||
@@ -435,12 +407,8 @@ describe('voteReward command', () => {
|
|||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
} as any,
|
} as any,
|
||||||
});
|
})
|
||||||
expect(legacyLateResult).toMatchObject({
|
).rejects.toThrow('authoritative daemon processing game tick');
|
||||||
type: 'voteReward',
|
|
||||||
ok: false,
|
|
||||||
reason: '설문조사가 종료되었습니다.',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -501,8 +469,8 @@ describe('voteReward command', () => {
|
|||||||
voteId,
|
voteId,
|
||||||
generalId: 1,
|
generalId: 1,
|
||||||
selection: [0],
|
selection: [0],
|
||||||
acceptedGameTick,
|
processingGameTick: acceptedGameTick,
|
||||||
},
|
} as any,
|
||||||
{ db: commandDb as any }
|
{ db: commandDb as any }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -602,7 +570,8 @@ describe('voteReward command', () => {
|
|||||||
voteId: 1,
|
voteId: 1,
|
||||||
generalId: 1,
|
generalId: 1,
|
||||||
selection: [0],
|
selection: [0],
|
||||||
},
|
processingGameTick: 0,
|
||||||
|
} as any,
|
||||||
{ db: commandDb as any }
|
{ db: commandDb as any }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const createGameServerActivityTracker = (): GameServerActivityTracker =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
lastContactAt: readonly(lastContactAt),
|
lastContactAt: readonly(lastContactAt),
|
||||||
markContact(contactAt = Date.now()) {
|
markContact(contactAt = performance.now()) {
|
||||||
if (!Number.isFinite(contactAt)) return;
|
if (!Number.isFinite(contactAt)) return;
|
||||||
lastContactAt.value = contactAt;
|
lastContactAt.value = contactAt;
|
||||||
},
|
},
|
||||||
@@ -21,7 +21,7 @@ export const createGameServerActivityTracker = (): GameServerActivityTracker =>
|
|||||||
|
|
||||||
export const isRecentGameServerActivity = (
|
export const isRecentGameServerActivity = (
|
||||||
lastContactAt: number | null,
|
lastContactAt: number | null,
|
||||||
now = Date.now(),
|
now = performance.now(),
|
||||||
freshnessMs = GAME_SERVER_ACTIVITY_FRESHNESS_MS
|
freshnessMs = GAME_SERVER_ACTIVITY_FRESHNESS_MS
|
||||||
): boolean =>
|
): boolean =>
|
||||||
lastContactAt !== null &&
|
lastContactAt !== null &&
|
||||||
@@ -31,4 +31,4 @@ export const isRecentGameServerActivity = (
|
|||||||
|
|
||||||
export const gameServerActivity = createGameServerActivityTracker();
|
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)) {
|
if (!canReadProfile(adminAuth, initialOperation.profileName)) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' });
|
||||||
}
|
}
|
||||||
const deadline = Date.now() + input.timeoutMs;
|
const deadline = performance.now() + input.timeoutMs;
|
||||||
while (true) {
|
while (true) {
|
||||||
const [operation, entries] = await Promise.all([
|
const [operation, entries] = await Promise.all([
|
||||||
ctx.profiles.getOperation(input.id),
|
ctx.profiles.getOperation(input.id),
|
||||||
@@ -1270,7 +1270,7 @@ export const adminRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' });
|
||||||
}
|
}
|
||||||
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
|
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
|
||||||
if (entries.length || terminal || Date.now() >= deadline) {
|
if (entries.length || terminal || performance.now() >= deadline) {
|
||||||
return {
|
return {
|
||||||
operation,
|
operation,
|
||||||
entries,
|
entries,
|
||||||
@@ -1942,7 +1942,7 @@ export const adminRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const deadline = Date.now() + input.timeoutMs;
|
const deadline = performance.now() + input.timeoutMs;
|
||||||
while (true) {
|
while (true) {
|
||||||
const [operation, entries] = await Promise.all([
|
const [operation, entries] = await Promise.all([
|
||||||
ctx.releases.getOperation(input.id),
|
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.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Gateway release operation not found.' });
|
||||||
}
|
}
|
||||||
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
|
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
|
||||||
if (entries.length || terminal || Date.now() >= deadline) {
|
if (entries.length || terminal || performance.now() >= deadline) {
|
||||||
return {
|
return {
|
||||||
operation,
|
operation,
|
||||||
entries,
|
entries,
|
||||||
@@ -2519,8 +2519,8 @@ export const adminRouter = router({
|
|||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
requestedBy: adminAuth.user.id,
|
requestedBy: adminAuth.user.id,
|
||||||
});
|
});
|
||||||
const deadline = Date.now() + 10 * 60_000;
|
const deadline = performance.now() + 10 * 60_000;
|
||||||
while (Date.now() < deadline) {
|
while (performance.now() < deadline) {
|
||||||
await ctx.orchestrator.runOperationsNow();
|
await ctx.orchestrator.runOperationsNow();
|
||||||
const current = await ctx.profiles.getOperation(operation.id);
|
const current = await ctx.profiles.getOperation(operation.id);
|
||||||
if (current?.status === 'SUCCEEDED') {
|
if (current?.status === 'SUCCEEDED') {
|
||||||
|
|||||||
@@ -2995,7 +2995,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
profile: GatewayProfileRecord,
|
profile: GatewayProfileRecord,
|
||||||
assertLease?: () => Promise<void>
|
assertLease?: () => Promise<void>
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const deadline = Date.now() + this.profileReadinessTimeoutMs;
|
const deadline = performance.now() + this.profileReadinessTimeoutMs;
|
||||||
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
||||||
const expectedNames = Object.entries(definitions)
|
const expectedNames = Object.entries(definitions)
|
||||||
.filter(([role]) => this.frontendServeMode === 'preview' || role !== 'frontend')
|
.filter(([role]) => this.frontendServeMode === 'preview' || role !== 'frontend')
|
||||||
@@ -3008,7 +3008,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
this.processConfig.frontendReadinessOrigin ?? 'http://caddy'
|
this.processConfig.frontendReadinessOrigin ?? 'http://caddy'
|
||||||
).toString()
|
).toString()
|
||||||
: `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`;
|
: `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`;
|
||||||
while (Date.now() < deadline) {
|
while (performance.now() < deadline) {
|
||||||
await assertLease?.();
|
await assertLease?.();
|
||||||
try {
|
try {
|
||||||
const [api, frontend, processes] = await Promise.all([
|
const [api, frontend, processes] = await Promise.all([
|
||||||
|
|||||||
@@ -242,13 +242,19 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
});
|
});
|
||||||
return mapOperation(row);
|
return mapOperation(row);
|
||||||
},
|
},
|
||||||
async claimNextOperation(now, lease) {
|
async claimNextOperation(_now, lease) {
|
||||||
const row = await prisma.$transaction(async (tx) => {
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
await tx.$queryRaw<Array<{ lock_result: string }>>`
|
await tx.$queryRaw<Array<{ lock_result: string }>>`
|
||||||
SELECT pg_advisory_xact_lock(
|
SELECT pg_advisory_xact_lock(
|
||||||
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
|
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
|
||||||
)::text AS lock_result
|
)::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({
|
const runningProfileOperation = await tx.gatewayOperation.findFirst({
|
||||||
where: { status: 'RUNNING' },
|
where: { status: 'RUNNING' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -326,14 +332,22 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
});
|
});
|
||||||
return row ? mapOperation(row) : null;
|
return row ? mapOperation(row) : null;
|
||||||
},
|
},
|
||||||
async renewOperationLease(id, ownerId, now, durationMs) {
|
async renewOperationLease(id, ownerId, _now, durationMs) {
|
||||||
const updated = await prisma.gatewayReleaseOperation.updateMany({
|
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 },
|
where: { id, status: 'RUNNING', leaseOwner: ownerId },
|
||||||
data: {
|
data: {
|
||||||
leaseUntil: new Date(now.getTime() + durationMs),
|
leaseUntil: new Date(now.getTime() + durationMs),
|
||||||
heartbeatAt: now,
|
heartbeatAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return updated.count === 1;
|
return updated.count === 1;
|
||||||
},
|
},
|
||||||
async pinOperationResolvedCommit(id, ownerId, resolvedCommitSha) {
|
async pinOperationResolvedCommit(id, ownerId, resolvedCommitSha) {
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
return mapOperation(row);
|
return mapOperation(row);
|
||||||
},
|
},
|
||||||
async claimNextOperation(
|
async claimNextOperation(
|
||||||
now: Date,
|
_now: Date,
|
||||||
lease?: { ownerId: string; durationMs: number }
|
lease?: { ownerId: string; durationMs: number }
|
||||||
): Promise<GatewayOperationRecord | null> {
|
): Promise<GatewayOperationRecord | null> {
|
||||||
const row = await prisma.$transaction(async (tx) => {
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
@@ -700,6 +700,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
|
hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0)
|
||||||
)::text AS lock_result
|
)::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({
|
const runningRelease = await tx.gatewayReleaseOperation.findFirst({
|
||||||
where: { status: 'RUNNING' },
|
where: { status: 'RUNNING' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -815,14 +821,22 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
});
|
});
|
||||||
return row ? mapOperation(row) : null;
|
return row ? mapOperation(row) : null;
|
||||||
},
|
},
|
||||||
async renewOperationLease(id: string, ownerId: string, now: Date, durationMs: number): Promise<boolean> {
|
async renewOperationLease(id: string, ownerId: string, _now: Date, durationMs: number): Promise<boolean> {
|
||||||
const renewed = await prisma.gatewayOperation.updateMany({
|
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 },
|
where: { id, status: 'RUNNING', leaseOwner: ownerId },
|
||||||
data: {
|
data: {
|
||||||
leaseUntil: new Date(now.getTime() + durationMs),
|
leaseUntil: new Date(now.getTime() + durationMs),
|
||||||
heartbeatAt: now,
|
heartbeatAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return renewed.count === 1;
|
return renewed.count === 1;
|
||||||
},
|
},
|
||||||
async pinOperationResolvedCommit(id: string, ownerId: string, resolvedCommitSha: string): Promise<boolean> {
|
async pinOperationResolvedCommit(id: string, ownerId: string, resolvedCommitSha: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -293,13 +293,13 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null })
|
|||||||
}
|
}
|
||||||
if (!gitRef) {
|
if (!gitRef) {
|
||||||
const cached = previewCache.get(DEFAULT_CACHE_KEY);
|
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;
|
return cached.data;
|
||||||
}
|
}
|
||||||
const ids = await listScenarioIds();
|
const ids = await listScenarioIds();
|
||||||
const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id)));
|
const previews = await Promise.all(ids.map((id) => buildScenarioPreview(id)));
|
||||||
previewCache.set(DEFAULT_CACHE_KEY, {
|
previewCache.set(DEFAULT_CACHE_KEY, {
|
||||||
loadedAt: Date.now(),
|
loadedAt: performance.now(),
|
||||||
data: previews,
|
data: previews,
|
||||||
});
|
});
|
||||||
return previews;
|
return previews;
|
||||||
@@ -308,7 +308,7 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null })
|
|||||||
const commitSha = await resolveGitCommitSha(gitRef);
|
const commitSha = await resolveGitCommitSha(gitRef);
|
||||||
const cacheKey = commitSha;
|
const cacheKey = commitSha;
|
||||||
const cached = previewCache.get(cacheKey);
|
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;
|
return cached.data;
|
||||||
}
|
}
|
||||||
const ids = await listScenarioIdsFromGit(commitSha);
|
const ids = await listScenarioIdsFromGit(commitSha);
|
||||||
@@ -317,7 +317,7 @@ export const listScenarioPreviews = async (options?: { gitRef?: string | null })
|
|||||||
previews.push(await buildScenarioPreviewFromGit(commitSha, id));
|
previews.push(await buildScenarioPreviewFromGit(commitSha, id));
|
||||||
}
|
}
|
||||||
previewCache.set(cacheKey, {
|
previewCache.set(cacheKey, {
|
||||||
loadedAt: Date.now(),
|
loadedAt: performance.now(),
|
||||||
data: previews,
|
data: previews,
|
||||||
});
|
});
|
||||||
return previews;
|
return previews;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WEB_PUSH_EVENT_TYPES,
|
WEB_PUSH_EVENT_TYPES,
|
||||||
@@ -409,10 +410,13 @@ export class WebPushCoordinator {
|
|||||||
`);
|
`);
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
const ids = rows.map((row) => row.id);
|
const ids = rows.map((row) => row.id);
|
||||||
await tx.webPushDelivery.updateMany({
|
await tx.$executeRaw(GatewayPrisma.sql`
|
||||||
where: { id: { in: ids } },
|
UPDATE web_push_delivery
|
||||||
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
SET locked_at = CURRENT_TIMESTAMP,
|
||||||
});
|
lock_owner = ${this.owner},
|
||||||
|
attempts = attempts + 1
|
||||||
|
WHERE id IN (${GatewayPrisma.join(ids)})
|
||||||
|
`);
|
||||||
return tx.webPushDelivery.findMany({
|
return tx.webPushDelivery.findMany({
|
||||||
where: { id: { in: ids }, lockOwner: this.owner },
|
where: { id: { in: ids }, lockOwner: this.owner },
|
||||||
include: { notification: true, subscription: true },
|
include: { notification: true, subscription: true },
|
||||||
@@ -421,22 +425,31 @@ export class WebPushCoordinator {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const delivery of claimed) {
|
for (const delivery of claimed) {
|
||||||
if (delivery.subscription.expirationTime && delivery.subscription.expirationTime.getTime() <= Date.now()) {
|
const expired = await this.prisma.$transaction(async (tx) => {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
const count = await tx.$executeRaw(GatewayPrisma.sql`
|
||||||
await tx.webPushDelivery.updateMany({
|
UPDATE web_push_delivery AS delivery
|
||||||
where: { id: delivery.id, lockOwner: this.owner },
|
SET status = 'FAILED'::"WebPushDeliveryStatus",
|
||||||
data: {
|
locked_at = NULL,
|
||||||
status: 'FAILED',
|
lock_owner = NULL,
|
||||||
lockedAt: null,
|
last_error = 'Push subscription expired.'
|
||||||
lockOwner: null,
|
FROM web_push_subscription AS subscription
|
||||||
lastError: 'Push subscription expired.',
|
WHERE delivery.id = ${delivery.id}
|
||||||
},
|
AND delivery.lock_owner = ${this.owner}
|
||||||
});
|
AND subscription.id = delivery.subscription_id
|
||||||
await tx.webPushSubscription.update({
|
AND subscription.expiration_time IS NOT NULL
|
||||||
where: { id: delivery.subscriptionId },
|
AND subscription.expiration_time <= CURRENT_TIMESTAMP
|
||||||
data: { disabledAt: new Date() },
|
`);
|
||||||
});
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -453,16 +466,15 @@ export class WebPushCoordinator {
|
|||||||
}),
|
}),
|
||||||
{ TTL: 60 * 60 }
|
{ TTL: 60 * 60 }
|
||||||
);
|
);
|
||||||
await this.prisma.webPushDelivery.updateMany({
|
await this.prisma.$executeRaw(GatewayPrisma.sql`
|
||||||
where: { id: delivery.id, lockOwner: this.owner },
|
UPDATE web_push_delivery
|
||||||
data: {
|
SET status = 'DELIVERED'::"WebPushDeliveryStatus",
|
||||||
status: 'DELIVERED',
|
delivered_at = CURRENT_TIMESTAMP,
|
||||||
deliveredAt: new Date(),
|
locked_at = NULL,
|
||||||
lockedAt: null,
|
lock_owner = NULL,
|
||||||
lockOwner: null,
|
last_error = NULL
|
||||||
lastError: null,
|
WHERE id = ${delivery.id} AND lock_owner = ${this.owner}
|
||||||
},
|
`);
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const statusCode =
|
const statusCode =
|
||||||
typeof error === 'object' && error !== null && 'statusCode' in error
|
typeof error === 'object' && error !== null && 'statusCode' in error
|
||||||
@@ -478,28 +490,31 @@ export class WebPushCoordinator {
|
|||||||
const safeError =
|
const safeError =
|
||||||
statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
|
statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.webPushDelivery.updateMany({
|
const nextStatus = terminal || exhausted ? 'FAILED' : 'PENDING';
|
||||||
where: { id: delivery.id, lockOwner: this.owner },
|
await tx.$executeRaw(GatewayPrisma.sql`
|
||||||
data: {
|
UPDATE web_push_delivery
|
||||||
status: terminal || exhausted ? 'FAILED' : 'PENDING',
|
SET status = ${nextStatus}::"WebPushDeliveryStatus",
|
||||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
available_at = CURRENT_TIMESTAMP
|
||||||
lockedAt: null,
|
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
||||||
lockOwner: null,
|
locked_at = NULL,
|
||||||
lastError: safeError,
|
lock_owner = NULL,
|
||||||
},
|
last_error = ${safeError}
|
||||||
});
|
WHERE id = ${delivery.id} AND lock_owner = ${this.owner}
|
||||||
|
`);
|
||||||
if (statusCode === 404 || statusCode === 410) {
|
if (statusCode === 404 || statusCode === 410) {
|
||||||
await tx.webPushSubscription.update({
|
await tx.$executeRaw(GatewayPrisma.sql`
|
||||||
where: { id: delivery.subscriptionId },
|
UPDATE web_push_subscription
|
||||||
data: { disabledAt: new Date() },
|
SET disabled_at = CURRENT_TIMESTAMP,
|
||||||
});
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ${delivery.subscriptionId}
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!terminal) this.onError(new Error(safeError));
|
if (!terminal) this.onError(new Error(safeError));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Date.now() >= this.nextPruneAt) {
|
if (performance.now() >= this.nextPruneAt) {
|
||||||
this.nextPruneAt = Date.now() + 60_000;
|
this.nextPruneAt = performance.now() + 60_000;
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.$executeRaw(GatewayPrisma.sql`
|
await tx.$executeRaw(GatewayPrisma.sql`
|
||||||
WITH expired AS (
|
WITH expired AS (
|
||||||
@@ -534,7 +549,7 @@ export class WebPushCoordinator {
|
|||||||
|
|
||||||
private run(): void {
|
private run(): void {
|
||||||
if (!this.configured || this.inFlight) return;
|
if (!this.configured || this.inFlight) return;
|
||||||
const now = Date.now();
|
const now = performance.now();
|
||||||
const shouldReconcileProfiles = now >= this.nextProfileReconcileAt;
|
const shouldReconcileProfiles = now >= this.nextProfileReconcileAt;
|
||||||
if (shouldReconcileProfiles) this.nextProfileReconcileAt = now + 5_000;
|
if (shouldReconcileProfiles) this.nextProfileReconcileAt = now + 5_000;
|
||||||
this.inFlight = (shouldReconcileProfiles ? this.reconcileProfiles() : Promise.resolve())
|
this.inFlight = (shouldReconcileProfiles ? this.reconcileProfiles() : Promise.resolve())
|
||||||
|
|||||||
@@ -41,13 +41,16 @@ describeDatabase('gateway release operation persistence', () => {
|
|||||||
).rejects.toMatchObject({ code: 'P2002' });
|
).rejects.toMatchObject({ code: 'P2002' });
|
||||||
|
|
||||||
const now = new Date('2030-01-01T00:00:00.000Z');
|
const now = new Date('2030-01-01T00:00:00.000Z');
|
||||||
await expect(
|
const claimed = await repository.claimNextOperation(now, {
|
||||||
repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 })
|
ownerId: 'controller-a',
|
||||||
).resolves.toMatchObject({
|
durationMs: 1_000,
|
||||||
|
});
|
||||||
|
expect(claimed).toMatchObject({
|
||||||
id: operation.id,
|
id: operation.id,
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
leaseOwner: 'controller-a',
|
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(
|
await expect(repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'a'.repeat(40))).resolves.toBe(
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@@ -103,6 +106,11 @@ describeDatabase('gateway release operation persistence', () => {
|
|||||||
await repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 });
|
await repository.claimNextOperation(now, { ownerId: 'controller-a', durationMs: 1_000 });
|
||||||
await repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'c'.repeat(40));
|
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(
|
await expect(
|
||||||
repository.claimNextOperation(new Date(now.getTime() + 1_001), {
|
repository.claimNextOperation(new Date(now.getTime() + 1_001), {
|
||||||
ownerId: 'controller-b',
|
ownerId: 'controller-b',
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
repository.claimNextOperation(beforeReset, { ownerId: 'worker-b', durationMs: 1_000 })
|
repository.claimNextOperation(beforeReset, { ownerId: 'worker-b', durationMs: 1_000 })
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
|
await connector.prisma.$executeRaw`
|
||||||
|
UPDATE "gateway_operation"
|
||||||
|
SET "scheduled_at" = CURRENT_TIMESTAMP - INTERVAL '1 second'
|
||||||
|
WHERE "id" = ${scheduledReset.id}
|
||||||
|
`;
|
||||||
await expect(
|
await expect(
|
||||||
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-b', durationMs: 1_000 })
|
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-b', durationMs: 1_000 })
|
||||||
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
|
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
|
||||||
@@ -168,13 +173,18 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
|||||||
sourceRef: 'b'.repeat(40),
|
sourceRef: 'b'.repeat(40),
|
||||||
requestedBy: 'deploy-admin',
|
requestedBy: 'deploy-admin',
|
||||||
});
|
});
|
||||||
|
await connector.prisma.$executeRaw`
|
||||||
|
UPDATE "gateway_operation"
|
||||||
|
SET "scheduled_at" = CURRENT_TIMESTAMP - INTERVAL '1 second'
|
||||||
|
WHERE "id" = ${scheduledReset.id}
|
||||||
|
`;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-a', durationMs: 1_000 })
|
repository.claimNextOperation(scheduledAt, { ownerId: 'worker-a', durationMs: 1_000 })
|
||||||
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
|
).resolves.toMatchObject({ id: scheduledReset.id, type: 'RESET', status: 'RUNNING' });
|
||||||
await expect(repository.getOperation(interimDeploy.id)).resolves.toMatchObject({
|
await expect(repository.getOperation(interimDeploy.id)).resolves.toMatchObject({
|
||||||
status: 'CANCELLED',
|
status: 'CANCELLED',
|
||||||
completedAt: scheduledAt.toISOString(),
|
completedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
await expect(repository.listOperationLogs(interimDeploy.id)).resolves.toEqual([
|
await expect(repository.listOperationLogs(interimDeploy.id)).resolves.toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -405,6 +415,11 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
|||||||
durationMs: 1_000,
|
durationMs: 1_000,
|
||||||
})
|
})
|
||||||
).resolves.toBeNull();
|
).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), {
|
const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), {
|
||||||
ownerId: 'worker-b',
|
ownerId: 'worker-b',
|
||||||
durationMs: 1_000,
|
durationMs: 1_000,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
|
|||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
|
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;
|
let inFlight: Promise<AdminProfileNavigationItem[]> | undefined;
|
||||||
|
|
||||||
export const loadAdminProfileNavigation = async (): Promise<AdminProfileNavigationItem[]> => {
|
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;
|
if (inFlight) return inFlight;
|
||||||
inFlight = directTrpc.admin.profiles.listNavigation
|
inFlight = directTrpc.admin.profiles.listNavigation
|
||||||
.query()
|
.query()
|
||||||
.then((profiles) => {
|
.then((profiles) => {
|
||||||
cachedProfiles = profiles as AdminProfileNavigationItem[];
|
cachedProfiles = profiles as AdminProfileNavigationItem[];
|
||||||
cachedAt = Date.now();
|
cachedAt = performance.now();
|
||||||
return cachedProfiles;
|
return cachedProfiles;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const main = async (): Promise<void> => {
|
|||||||
process.once('SIGINT', () => void stop());
|
process.once('SIGINT', () => void stop());
|
||||||
process.once('SIGTERM', () => void stop());
|
process.once('SIGTERM', () => void stop());
|
||||||
while (!stopping) {
|
while (!stopping) {
|
||||||
const now = Date.now();
|
const now = performance.now();
|
||||||
if (now >= nextWorkspaceCleanupAt) {
|
if (now >= nextWorkspaceCleanupAt) {
|
||||||
nextWorkspaceCleanupAt = now + RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS;
|
nextWorkspaceCleanupAt = now + RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ export class GatewayReleaseController {
|
|||||||
|
|
||||||
private async waitForReadiness(operationId: string): Promise<void> {
|
private async waitForReadiness(operationId: string): Promise<void> {
|
||||||
await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
|
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 apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
|
||||||
const frontendUrl =
|
const frontendUrl =
|
||||||
this.config.frontendServeMode === 'static'
|
this.config.frontendServeMode === 'static'
|
||||||
@@ -522,7 +522,7 @@ export class GatewayReleaseController {
|
|||||||
const expectedNames = buildGatewayProcessDefinitions(this.config.workspaceRoot, this.config).map(
|
const expectedNames = buildGatewayProcessDefinitions(this.config.workspaceRoot, this.config).map(
|
||||||
(definition) => definition.name
|
(definition) => definition.name
|
||||||
);
|
);
|
||||||
while (Date.now() < deadline) {
|
while (performance.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
const [api, frontend] = await Promise.all([
|
const [api, frontend] = await Promise.all([
|
||||||
this.fetchImpl(apiUrl),
|
this.fetchImpl(apiUrl),
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ export const upgradeReleaseController = async (options: {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await options.processManager.start(buildReleaseControllerDefinition(workspace.root, options.config));
|
await options.processManager.start(buildReleaseControllerDefinition(workspace.root, options.config));
|
||||||
const deadline = Date.now() + (options.readinessTimeoutMs ?? options.config.readinessTimeoutMs);
|
const deadline = performance.now() + (options.readinessTimeoutMs ?? options.config.readinessTimeoutMs);
|
||||||
while (Date.now() < deadline) {
|
while (performance.now() < deadline) {
|
||||||
const matching = (await options.processManager.list()).filter(
|
const matching = (await options.processManager.list()).filter(
|
||||||
(process) => process.name === CONTROLLER_PROCESS_NAME
|
(process) => process.name === CONTROLLER_PROCESS_NAME
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,13 +16,23 @@
|
|||||||
"world_state.deadline_generation",
|
"world_state.deadline_generation",
|
||||||
"general.turn_tick",
|
"general.turn_tick",
|
||||||
"general.recent_war_tick",
|
"general.recent_war_tick",
|
||||||
|
"general.meta.next_change_tick",
|
||||||
"select_pool.reserved_until_tick",
|
"select_pool.reserved_until_tick",
|
||||||
"select_npc_token.valid_until_tick",
|
"select_npc_token.valid_until_tick",
|
||||||
"select_npc_token.pick_more_from_tick",
|
"select_npc_token.pick_more_from_tick",
|
||||||
"message.time_tick",
|
"message.time_tick",
|
||||||
"message.valid_until_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.open_tick",
|
||||||
"auction.close_tick",
|
"auction.close_tick",
|
||||||
|
"auction_bid.occurred_game_tick",
|
||||||
"vote_poll.start_tick",
|
"vote_poll.start_tick",
|
||||||
"vote_poll.end_tick",
|
"vote_poll.end_tick",
|
||||||
"clock_suspension.source_revision",
|
"clock_suspension.source_revision",
|
||||||
@@ -34,6 +44,43 @@
|
|||||||
"clock_suspension.aligned_tick",
|
"clock_suspension.aligned_tick",
|
||||||
"clock_projection_outbox.target_revision"
|
"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": [
|
"participants": [
|
||||||
{
|
{
|
||||||
"key": "world-clock",
|
"key": "world-clock",
|
||||||
@@ -56,6 +103,13 @@
|
|||||||
"projectionFields": ["general.turn_time"],
|
"projectionFields": ["general.turn_time"],
|
||||||
"owner": "game-engine/turn-daemon"
|
"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",
|
"key": "general-recent-war-occurrence",
|
||||||
"policy": "KEEP",
|
"policy": "KEEP",
|
||||||
@@ -67,8 +121,15 @@
|
|||||||
"key": "auction-open-occurrence",
|
"key": "auction-open-occurrence",
|
||||||
"policy": "KEEP",
|
"policy": "KEEP",
|
||||||
"authorityFields": ["auction.open_tick"],
|
"authorityFields": ["auction.open_tick"],
|
||||||
"projectionFields": ["auction.created_at"],
|
"projectionFields": [],
|
||||||
"owner": "game-api/auction"
|
"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",
|
"key": "auction-deadline",
|
||||||
@@ -85,18 +146,37 @@
|
|||||||
"owner": "game-api/auction-worker"
|
"owner": "game-api/auction-worker"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "message-occurrence",
|
"key": "message-action-occurrence",
|
||||||
"policy": "KEEP",
|
"policy": "KEEP",
|
||||||
"authorityFields": ["message.time_tick"],
|
"authorityFields": ["message_action.created_game_tick"],
|
||||||
"projectionFields": ["message.time"],
|
"projectionFields": ["message.time", "message.time_tick"],
|
||||||
"owner": "game-engine/message"
|
"rowScope": "messages with a message_action row only",
|
||||||
|
"owner": "game-engine/message-action"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "message-expiry",
|
"key": "message-action-expiry",
|
||||||
"policy": "SHIFT",
|
"policy": "SHIFT",
|
||||||
"authorityFields": ["message.valid_until_tick"],
|
"authorityFields": ["message_action.expires_game_tick"],
|
||||||
"projectionFields": ["message.valid_until"],
|
"projectionFields": ["message.valid_until", "message.valid_until_tick"],
|
||||||
"owner": "game-engine/message"
|
"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",
|
"key": "vote-start-occurrence",
|
||||||
@@ -127,7 +207,7 @@
|
|||||||
"owner": "game-engine/npc-selection"
|
"owner": "game-engine/npc-selection"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "accepted-command-coordinate",
|
"key": "daemon-command-coordinate",
|
||||||
"policy": "KEEP",
|
"policy": "KEEP",
|
||||||
"authorityFields": [
|
"authorityFields": [
|
||||||
"input_event.accepted_game_tick",
|
"input_event.accepted_game_tick",
|
||||||
@@ -139,7 +219,7 @@
|
|||||||
"input_event.processing_clock_revision",
|
"input_event.processing_clock_revision",
|
||||||
"input_event.processing_deadline_generation"
|
"input_event.processing_deadline_generation"
|
||||||
],
|
],
|
||||||
"owner": "game-api/input-event"
|
"owner": "game-engine/input-event-claim"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "tournament-deadlines",
|
"key": "tournament-deadlines",
|
||||||
@@ -220,6 +300,17 @@
|
|||||||
"clock_projection_outbox.available_at",
|
"clock_projection_outbox.available_at",
|
||||||
"clock_projection_outbox.locked_at",
|
"clock_projection_outbox.locked_at",
|
||||||
"clock_projection_outbox.applied_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",
|
"*.created_at",
|
||||||
"*.updated_at"
|
"*.updated_at"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
## Product contract
|
## Product contract
|
||||||
|
|
||||||
Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000`
|
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
|
ticks. Wall time is separately authoritative for account, community, audit,
|
||||||
authority for gameplay ordering. A long suspension advances the observed game
|
lease, retry, notification, and operational rules. It is never projected into a
|
||||||
coordinate to the resume wall instant without replaying skipped turns, monthly
|
game deadline. A long suspension advances the observed game coordinate to the
|
||||||
events, RNG, auctions, or tournaments. Every movable future schedule is shifted
|
resume wall instant without replaying skipped turns, monthly events, RNG,
|
||||||
by the same exact tick delta, including the sub-turn remainder.
|
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`:
|
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
|
architecture gate rejects a new tick/revision field that is absent from that
|
||||||
inventory.
|
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
|
## Unification wait
|
||||||
|
|
||||||
A unification month with an invader choice changes `RUNNING -> SUSPENDED` and
|
A unification month with an invader choice changes `RUNNING -> SUSPENDED` and
|
||||||
@@ -109,9 +121,11 @@ turn-daemon fencing row
|
|||||||
-> Redis outbox projection
|
-> Redis outbox projection
|
||||||
```
|
```
|
||||||
|
|
||||||
The ordinary turn flush already validates phase, revision, and deadline
|
The ordinary turn flush and daemon command claim validate phase, revision, and
|
||||||
generation after taking this lock prefix. Clock operation participants will be
|
deadline generation after taking this lock prefix. WALL-only message/account
|
||||||
added without changing that prefix.
|
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
|
## 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
|
treat the row as legacy `MANUAL`; the first fenced flush installs the complete
|
||||||
snapshot atomically instead of trusting the new column's `RUNNING` database
|
snapshot atomically instead of trusting the new column's `RUNNING` database
|
||||||
default. Input-event acceptance does not use that compatibility fallback: an
|
default. Input-event acceptance does not use that compatibility fallback: an
|
||||||
API or worker may enqueue gameplay only after the authoritative clock is fully
|
API or worker records only a DB-wall receipt, then the daemon establishes the
|
||||||
initialized.
|
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
|
No active participant remains `FORBID`. Tournament writes carry
|
||||||
tick/revision/generation coordinates and are revision-fenced in Redis.
|
tick/revision/generation coordinates and are revision-fenced in Redis.
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
# 게임 시계
|
# 게임 시계
|
||||||
|
|
||||||
게임 진행 시각은 `world_state.clock_tick`이 기준입니다. 벽시계는 daemon lease,
|
시간 규칙은 `GAME_TIME`, `WALL_TIME`, `MONOTONIC_ELAPSED_TIME`로
|
||||||
요청 timeout, 처리 budget과 같은 운영 제어에만 사용합니다. 장수 턴, 메시지
|
나뉩니다. 게임 진행의 권위는 `world_state.clock_tick`, 영속 wall
|
||||||
유효기간, 투표, 경매와 대회 마감은 game tick 또는 그 tick에서 투영한 시각을
|
판정의 권위는 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`을 다시 계산하므로, 기존 장수 턴 순서와
|
시각이 유지되도록 `clock_base_time`을 다시 계산하므로, 기존 장수 턴 순서와
|
||||||
남은 턴 수가 보존됩니다. DateTime 필드는 이전 데이터와 화면을 위한 투영값이며
|
남은 턴 수가 보존됩니다. GAME 규칙의 DateTime은 화면/레거시 투영일 뿐이며
|
||||||
tick 필드가 존재하면 tick이 우선합니다.
|
tick이 반드시 authority입니다. WALL 규칙은 tick이 없어도 정상이며
|
||||||
|
DateTime을 tick으로 변환해 판정하지 않습니다.
|
||||||
|
|
||||||
운영 중 턴 간격 변경은 Gateway의 내구성 런타임 작업으로만 수행합니다. 같은
|
운영 중 턴 간격 변경은 Gateway의 내구성 런타임 작업으로만 수행합니다. 같은
|
||||||
transaction에서 `world_state`, 장수·경매·메시지·설문 투영값과 checkpoint를
|
transaction에서 `world_state`, 장수·경매·actionable message·설문 투영값과 checkpoint를
|
||||||
갱신하며 기존 역사/행동 로그의 `created_at`은 다시 쓰지 않습니다. 토너먼트의
|
갱신하며 기존 역사/행동 로그의 `created_at`은 다시 쓰지 않습니다. 토너먼트의
|
||||||
Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
|
Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
|
||||||
|
|
||||||
@@ -29,6 +32,18 @@ Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
|
|||||||
프로필 설치 시 선택한 모드는 DB에 저장됩니다. daemon의 환경변수는 로드한
|
프로필 설치 시 선택한 모드는 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()`와 같은 한도를 적용합니다.
|
realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용합니다.
|
||||||
@@ -45,21 +60,24 @@ realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용
|
|||||||
장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행
|
장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행
|
||||||
시각으로 다시 고정합니다.
|
시각으로 다시 고정합니다.
|
||||||
|
|
||||||
DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 migration
|
DB migration은 GAME 규칙의 기존 DateTime 투영에서 tick을 채웁니다. 새 설치와 migration
|
||||||
재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은
|
재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은
|
||||||
무기한 호환값은 안전한 정수 범위를 넘을 수 있으므로 tick을 `NULL`로 두고
|
무기한 호환값은 일반 메시지의 투영일 뿐입니다. actionable deadline은
|
||||||
DateTime fallback을 사용합니다.
|
`expires_game_tick`, 일반 삭제 deadline은 `delete_until_wall`만이 authority이며
|
||||||
|
NULL에 따라 다른 시계로 fallback하지 않습니다.
|
||||||
|
|
||||||
## 비동기 작업의 시계 경계
|
## 비동기 작업의 시계 경계
|
||||||
|
|
||||||
게임 규칙의 수락·입찰·예약 시각은 logical game time을 사용하지만 daemon
|
외부 요청은 `InputEvent.createdAt` DB WALL_TIME으로 접수합니다. API payload가
|
||||||
queue의 `InputEvent.createdAt`, worker history retention과 timeout은 운영
|
game tick을 미리 고정하지 않으며, daemon이 clock lock/fence 아래서 claim할 때
|
||||||
벽시계를 사용합니다. NPC 빙의 enqueue는 현재 logical game time을 event
|
`accepted_game_tick`/세대와 `processing_game_tick`/세대를 확정합니다. NPC,
|
||||||
payload의 `acceptedGameAt`에 고정합니다. queue에 들어갈 때 유효했던 token은
|
선택, 투표, 경매, 유산 효과는 처리 tick으로 검증하며 stale revision은
|
||||||
처리 전 game tick이 진행해도 이 저장된 논리 수락 시각으로 다시 검증합니다.
|
적용하지 않습니다. worker history retention·lease·retry는 DB WALL_TIME,
|
||||||
|
프로세스 대기 budget은 monotonic time입니다.
|
||||||
|
|
||||||
경매 입찰은 같은 logical tick에서 여러 번 일어날 수 있습니다. bid 표시
|
경매 입찰은 `requested_at_wall`로 현실 요청을, `occurred_game_tick`으로 GAME
|
||||||
시각은 같은 game time을 보존하고, optimistic 경합 판정은 임의 UUID의
|
사건을 별도 기록합니다. bid 표시 투영은 같은 game time을 보존하고,
|
||||||
|
optimistic 경합 판정은 임의 UUID의
|
||||||
사전순이 아니라 읽은 `latest_event_id`를 버전 토큰으로 사용합니다. worker
|
사전순이 아니라 읽은 `latest_event_id`를 버전 토큰으로 사용합니다. worker
|
||||||
재시작 시 `OPEN`은 `close_tick` deadline에, 이미 마감 판정이 끝난
|
재시작 시 `OPEN`은 `close_tick` deadline에, 이미 마감 판정이 끝난
|
||||||
`FINALIZING`은 현재 tick에 seed하여 durable finalization event 복구를 즉시
|
`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 observedGameInstantBrand: unique symbol;
|
||||||
declare const scheduleInstantBrand: unique symbol;
|
declare const scheduleInstantBrand: unique symbol;
|
||||||
declare const clockRevisionBrand: unique symbol;
|
declare const clockRevisionBrand: unique symbol;
|
||||||
|
declare const deadlineGenerationBrand: unique symbol;
|
||||||
declare const wallInstantBrand: unique symbol;
|
declare const wallInstantBrand: unique symbol;
|
||||||
|
declare const monotonicDurationBrand: unique symbol;
|
||||||
|
|
||||||
export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' };
|
export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' };
|
||||||
export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' };
|
export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' };
|
||||||
export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' };
|
export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' };
|
||||||
export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' };
|
export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' };
|
||||||
|
export type DeadlineGeneration = number & { readonly [deadlineGenerationBrand]: 'DeadlineGeneration' };
|
||||||
export type WallInstant = Date & { readonly [wallInstantBrand]: 'WallInstant' };
|
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 {
|
export interface ClockAlignmentPlan {
|
||||||
policy: ClockAlignmentPolicy;
|
policy: ClockAlignmentPolicy;
|
||||||
@@ -59,6 +64,13 @@ export const asClockRevision = (revision: number): ClockRevision => {
|
|||||||
return revision as 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 => {
|
export const asWallInstant = (instant: Date): WallInstant => {
|
||||||
if (Number.isNaN(instant.getTime())) {
|
if (Number.isNaN(instant.getTime())) {
|
||||||
throw new Error('Wall instant must be a valid date.');
|
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;
|
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');
|
export const inferClockPhase = (mode: GameClockMode): GameClockPhase => (mode === 'manual' ? 'MANUAL' : 'RUNNING');
|
||||||
|
|
||||||
const GAME_CLOCK_PHASES: readonly GameClockPhase[] = [
|
const GAME_CLOCK_PHASES: readonly GameClockPhase[] = [
|
||||||
@@ -136,8 +155,7 @@ const buildAlignmentPlan = (input: {
|
|||||||
const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000;
|
const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000;
|
||||||
const gapTicks = asGameTick(
|
const gapTicks = asGameTick(
|
||||||
requireSafeTick(
|
requireSafeTick(
|
||||||
wholeSeconds * input.ticksPerSecond +
|
wholeSeconds * input.ticksPerSecond + Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000)
|
||||||
Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const catchUpTicks = asGameTick(input.catchUpTicks ?? 0);
|
const catchUpTicks = asGameTick(input.catchUpTicks ?? 0);
|
||||||
@@ -250,7 +268,13 @@ export class GameClock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nowTick(wallNow: Date): number {
|
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;
|
return this.tick;
|
||||||
}
|
}
|
||||||
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
|
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface TournamentClockFence {
|
|||||||
phaseKey: string;
|
phaseKey: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
deadlineGeneration: number;
|
deadlineGeneration: number;
|
||||||
phase: 'RUNNING';
|
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
||||||
}
|
}
|
||||||
|
|
||||||
const WRITE_TOURNAMENT_PROJECTION_SCRIPT = `
|
const WRITE_TOURNAMENT_PROJECTION_SCRIPT = `
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export type TurnDaemonCommand =
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
auctionId: number;
|
auctionId: number;
|
||||||
expectedCloseAt?: string;
|
expectedCloseAt?: string;
|
||||||
expectedCloseTick?: number;
|
expectedCloseTick: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'auctionOpen';
|
type: 'auctionOpen';
|
||||||
@@ -260,7 +260,6 @@ export type TurnDaemonCommand =
|
|||||||
voteId: number;
|
voteId: number;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
selection: number[];
|
selection: number[];
|
||||||
acceptedGameTick?: number;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'setNationSetting';
|
type: 'setNationSetting';
|
||||||
@@ -386,15 +385,12 @@ export type TurnDaemonCommand =
|
|||||||
ownerLegacyPenalty?: Record<string, unknown>;
|
ownerLegacyPenalty?: Record<string, unknown>;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
tokenNonce: number;
|
tokenNonce: number;
|
||||||
acceptedGameAt?: string;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'selectPoolReserve';
|
type: 'selectPoolReserve';
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
seedOwnerIdentity: string | number;
|
seedOwnerIdentity: string | number;
|
||||||
acceptedGameAt: string;
|
|
||||||
acceptedGameTick?: number;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'selectPoolCreate';
|
type: 'selectPoolCreate';
|
||||||
@@ -407,8 +403,6 @@ export type TurnDaemonCommand =
|
|||||||
ownerPicture?: string;
|
ownerPicture?: string;
|
||||||
ownerImageServer?: number;
|
ownerImageServer?: number;
|
||||||
ownerIconRevision?: string;
|
ownerIconRevision?: string;
|
||||||
acceptedGameAt?: string;
|
|
||||||
acceptedGameTick?: number;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'selectPoolReselect';
|
type: 'selectPoolReselect';
|
||||||
@@ -416,8 +410,6 @@ export type TurnDaemonCommand =
|
|||||||
userId: string;
|
userId: string;
|
||||||
ownerDisplayName: string;
|
ownerDisplayName: string;
|
||||||
uniqueName: string;
|
uniqueName: string;
|
||||||
acceptedGameAt?: string;
|
|
||||||
acceptedGameTick?: number;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'auctionBid';
|
type: 'auctionBid';
|
||||||
@@ -426,7 +418,6 @@ export type TurnDaemonCommand =
|
|||||||
auctionId: number;
|
auctionId: number;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
acceptedGameTick?: number;
|
|
||||||
tryExtendCloseDate?: boolean;
|
tryExtendCloseDate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,6 +496,7 @@ export type TurnDaemonCommandResult =
|
|||||||
ok: true;
|
ok: true;
|
||||||
auctionId: number;
|
auctionId: number;
|
||||||
closeAt: string;
|
closeAt: string;
|
||||||
|
closeTick: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'auctionOpen';
|
type: 'auctionOpen';
|
||||||
@@ -835,6 +827,7 @@ export type TurnDaemonCommandResult =
|
|||||||
ok: true;
|
ok: true;
|
||||||
auctionId: number;
|
auctionId: number;
|
||||||
closeAt: string;
|
closeAt: string;
|
||||||
|
closeTick: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'auctionBid';
|
type: 'auctionBid';
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ import {
|
|||||||
} from '../src/time/GameClock.js';
|
} from '../src/time/GameClock.js';
|
||||||
|
|
||||||
describe('GameClock', () => {
|
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');
|
const baseTime = new Date('2042-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
it('projects the fixed Ref turn tick and ignores wall time in manual mode', () => {
|
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:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
|
||||||
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-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: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",
|
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
|
||||||
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
|
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
|
||||||
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
||||||
|
|||||||
@@ -438,15 +438,45 @@ model Message {
|
|||||||
type String
|
type String
|
||||||
src Int
|
src Int
|
||||||
dest Int
|
dest Int
|
||||||
|
/// Legacy game-date projection. Never use this as the wall occurrence authority.
|
||||||
time DateTime
|
time DateTime
|
||||||
|
/// Legacy game-date projection coordinate. New rules use occurredGameTick or MessageAction.
|
||||||
timeTick BigInt? @map("time_tick")
|
timeTick BigInt? @map("time_tick")
|
||||||
|
/// Legacy envelope/action visibility projection retained for rolling compatibility.
|
||||||
validUntil DateTime @map("valid_until")
|
validUntil DateTime @map("valid_until")
|
||||||
|
/// Legacy action deadline projection retained for rolling compatibility.
|
||||||
validUntilTick BigInt? @map("valid_until_tick")
|
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
|
message Json
|
||||||
|
|
||||||
|
action MessageAction?
|
||||||
|
|
||||||
|
@@index([mailbox, type, id])
|
||||||
|
@@index([deleteUntilWall])
|
||||||
@@map("message")
|
@@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 {
|
model RankData {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
nationId Int @default(0) @map("nation_id")
|
nationId Int @default(0) @map("nation_id")
|
||||||
@@ -857,6 +887,26 @@ model InheritanceLog {
|
|||||||
@@map("inheritance_log")
|
@@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 {
|
model InheritanceResult {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
legacyId Int? @unique @map("legacy_id")
|
legacyId Int? @unique @map("legacy_id")
|
||||||
@@ -902,14 +952,18 @@ model AuctionBid {
|
|||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
amount Int
|
amount Int
|
||||||
eventId String @map("event_id")
|
eventId String @map("event_id")
|
||||||
|
/// Legacy/UI projection of occurredGameTick. Never use as expiry authority.
|
||||||
eventAt DateTime @map("event_at")
|
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"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
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)
|
auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([auctionId, amount])
|
@@index([auctionId, amount])
|
||||||
@@index([auctionId, eventAt])
|
@@index([auctionId, eventAt])
|
||||||
|
@@index([auctionId, occurredGameTick])
|
||||||
@@map("auction_bid")
|
@@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`
|
- `select_npc_token`, `select_npc_token_valid_until_idx`
|
||||||
- `general_user_id_key`
|
- `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와 role만 제거합니다. 공유
|
||||||
database나 Compose volume을 삭제하지 않습니다.
|
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;
|
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
|
||||||
messageReadState: GamePrisma.MessageReadStateDelegate;
|
messageReadState: GamePrisma.MessageReadStateDelegate;
|
||||||
message: GamePrisma.MessageDelegate;
|
message: GamePrisma.MessageDelegate;
|
||||||
|
messageAction: GamePrisma.MessageActionDelegate;
|
||||||
city: GamePrisma.CityDelegate;
|
city: GamePrisma.CityDelegate;
|
||||||
nation: GamePrisma.NationDelegate;
|
nation: GamePrisma.NationDelegate;
|
||||||
diplomacy: GamePrisma.DiplomacyDelegate;
|
diplomacy: GamePrisma.DiplomacyDelegate;
|
||||||
@@ -38,6 +39,7 @@ export interface DatabaseClient {
|
|||||||
nationBetting: GamePrisma.NationBettingDelegate;
|
nationBetting: GamePrisma.NationBettingDelegate;
|
||||||
nationBet: GamePrisma.NationBetDelegate;
|
nationBet: GamePrisma.NationBetDelegate;
|
||||||
inheritanceLog: GamePrisma.InheritanceLogDelegate;
|
inheritanceLog: GamePrisma.InheritanceLogDelegate;
|
||||||
|
inheritanceLedger: GamePrisma.InheritanceLedgerDelegate;
|
||||||
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
||||||
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
|
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
|
||||||
boardPost: GamePrisma.BoardPostDelegate;
|
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