feat: add logical game clock

This commit is contained in:
2026-08-04 02:51:27 +00:00
parent 87965a39d6
commit a26031dc3f
51 changed files with 1605 additions and 398 deletions
+6 -1
View File
@@ -1,7 +1,9 @@
import { TRPCError } from '@trpc/server';
import type { GameApiContext } from '../context.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { buildAuctionTimerKeys } from './keys.js';
import { resolveAuctionTimerScore } from './scheduler.js';
export type OpenAuctionInput =
| {
@@ -36,7 +38,10 @@ export const openAuctionWithDaemon = async (
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const closeAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: closeAt.getTime(), value: String(result.auctionId) }]);
const gameTime = await loadCurrentGameTime(ctx.db);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) },
]);
return {
auctionId: result.auctionId,
closeAt: result.closeAt,
+25 -3
View File
@@ -3,6 +3,7 @@ import { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js';
import type { AuctionTimerRow } from './types.js';
import type { AuctionTimerKeys } from './keys.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
interface RedisSortedSetClient {
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
@@ -16,6 +17,15 @@ export interface AuctionEventUpdate {
eventAt: Date;
}
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
if (closeTick !== null && closeTick !== undefined) {
const value = Number(closeTick);
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
return value;
}
return time.dateToTick(closeAt) ?? closeAt.getTime();
};
export const seedAuctionTimers = async (
db: DatabaseClient,
redis: RedisSortedSetClient,
@@ -23,7 +33,7 @@ export const seedAuctionTimers = async (
): Promise<number> => {
const rows = await db.$queryRaw<AuctionTimerRow[]>(
GamePrisma.sql`
SELECT id, close_at as "closeAt", status
SELECT id, close_at as "closeAt", close_tick as "closeTick", status
FROM auction
WHERE status IN ('OPEN', 'FINALIZING')
`
@@ -32,7 +42,11 @@ export const seedAuctionTimers = async (
return 0;
}
const payload = rows.map((row) => ({ score: row.closeAt.getTime(), value: String(row.id) }));
const gameTime = await loadCurrentGameTime(db);
const payload = rows.map((row) => ({
score: resolveAuctionTimerScore(gameTime, row.closeAt, row.closeTick),
value: String(row.id),
}));
await redis.zAdd(keys.timerKey, payload);
return payload.length;
};
@@ -44,10 +58,13 @@ export const applyAuctionEvent = async (
event: AuctionEventUpdate
): Promise<boolean> => {
const now = new Date();
const gameTime = await loadCurrentGameTime(db, now);
const closeTick = gameTime.dateToTick(event.closeAt);
const updated = await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET close_at = ${event.closeAt},
close_tick = ${closeTick === null ? null : BigInt(closeTick)},
latest_event_id = ${event.eventId},
latest_event_at = ${event.eventAt},
updated_at = ${now}
@@ -61,7 +78,12 @@ export const applyAuctionEvent = async (
);
if (updated > 0) {
await redis.zAdd(keys.timerKey, [{ score: event.closeAt.getTime(), value: String(event.auctionId) }]);
await redis.zAdd(keys.timerKey, [
{
score: resolveAuctionTimerScore(gameTime, event.closeAt, closeTick === null ? null : BigInt(closeTick)),
value: String(event.auctionId),
},
]);
return true;
}
+1
View File
@@ -3,6 +3,7 @@ export type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
export interface AuctionTimerRow {
id: number;
closeAt: Date;
closeTick: bigint | null;
status: AuctionStatus;
}
+30 -15
View File
@@ -9,9 +9,10 @@ import {
import { resolveGameApiConfigFromEnv } from '../config.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { buildAuctionTimerKeys } from './keys.js';
import { seedAuctionTimers } from './scheduler.js';
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
interface RedisTimerClient {
zRangeByScore(
@@ -86,8 +87,9 @@ export const processDueAuctionId = async (options: {
historyKey: string;
id: string;
nowMs: number;
nowTick?: number | null;
}): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => {
const { db, redis, timerKey, historyKey, id, nowMs } = options;
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null } = options;
const auctionId = Number(id);
if (!Number.isSafeInteger(auctionId) || auctionId < 1) {
return 'IGNORED';
@@ -102,13 +104,16 @@ export const processDueAuctionId = async (options: {
updated_at = ${now}
WHERE id = ${auctionId}
AND status = 'OPEN'
AND close_at <= ${now}
AND (
(close_tick IS NOT NULL AND close_tick <= ${nowTick === null ? null : BigInt(nowTick)})
OR (close_tick IS NULL AND close_at <= ${now})
)
`
);
const current = await transaction.auction.findUnique({
where: { id: auctionId },
select: { status: true, closeAt: true },
select: { status: true, closeAt: true, closeTick: true },
});
if (!current) {
if (updated > 0) {
@@ -117,7 +122,7 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const };
}
if (current.status === 'OPEN') {
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt };
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
}
if (current.status !== 'FINALIZING') {
return { status: 'IGNORED' as const };
@@ -159,7 +164,13 @@ export const processDueAuctionId = async (options: {
return 'FINALIZING';
}
if (outcome.status === 'RESCHEDULED') {
await redis.zAdd(timerKey, [{ score: outcome.closeAt.getTime(), value: String(auctionId) }]);
const gameTime = await loadCurrentGameTime(db, now);
await redis.zAdd(timerKey, [
{
score: resolveAuctionTimerScore(gameTime, outcome.closeAt, outcome.closeTick),
value: String(auctionId),
},
]);
return 'RESCHEDULED';
}
return 'IGNORED';
@@ -188,17 +199,20 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
try {
while (!control.signal.aborted) {
const nowMs = Date.now();
const historyTrimBefore = nowMs - config.auctionTimerRetentionSeconds * 1000;
const operationalNowMs = Date.now();
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
const gameNowMs = gameTime.now.getTime();
const dueScore = gameTime.tick ?? gameNowMs;
const historyTrimBefore = operationalNowMs - config.auctionTimerRetentionSeconds * 1000;
if (historyTrimBefore > 0) {
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
}
if (nowMs >= nextResyncAt) {
if (operationalNowMs >= nextResyncAt) {
await seedAuctionTimers(postgres.prisma, redis.client, keys);
nextResyncAt = nowMs + config.auctionTimerResyncMs;
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
}
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, nowMs, 100);
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100);
if (dueIds.length > 0) {
for (const id of dueIds) {
try {
@@ -208,7 +222,8 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
timerKey: keys.timerKey,
historyKey: keys.historyKey,
id,
nowMs,
nowMs: gameNowMs,
nowTick: gameTime.tick,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown auction worker error';
@@ -233,9 +248,9 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
const nextDueMs = await getNextDueMs(redis.client, keys.timerKey);
const waitMs =
nextDueMs === null
? config.auctionTimerPollMs
: Math.max(0, Math.min(config.auctionTimerPollMs, nextDueMs - Date.now()));
gameTime.tick === null && nextDueMs !== null
? Math.max(0, Math.min(config.auctionTimerPollMs, nextDueMs - gameNowMs))
: config.auctionTimerPollMs;
await waitForWorkerPoll(control.signal, waitMs);
}
} finally {
+11 -2
View File
@@ -56,8 +56,17 @@ export const zWorldStateMeta = z.object({
});
export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
export type WorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
export type GeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick';
type GeneralClockFields = 'turnTick' | 'recentWarTick';
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma
// rows always include these nullable columns after migration.
export type WorldStateRow = Omit<PrismaWorldStateRow, WorldClockFields> &
Partial<Pick<PrismaWorldStateRow, WorldClockFields>>;
export type GeneralRow = Omit<PrismaGeneralRow, GeneralClockFields> &
Partial<Pick<PrismaGeneralRow, GeneralClockFields>>;
export type GeneralTurnRow = GamePrisma.GeneralTurnGetPayload<Record<string, never>>;
export type NationTurnRow = GamePrisma.NationTurnGetPayload<Record<string, never>>;
export type CityRow = GamePrisma.CityGetPayload<Record<string, never>>;
@@ -20,6 +20,7 @@ import {
import type { DatabaseClient, GeneralRow, InputJsonValue, NationRow } from '../context.js';
import { loadMapDefinitionByName } from '../maps/mapDefinition.js';
import { resolveNationPermission } from '../router/nation/shared.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { fetchMessageByIdForUpdate, insertMessage, invalidateMessages } from './store.js';
const ACTION_NAMES: Record<InstantDiplomacyResponseAction, string> = {
@@ -186,7 +187,7 @@ export const respondToDiplomaticMessage = async (options: {
if (!world) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '게임 상태가 없습니다.' });
}
const now = new Date();
const now = (await loadCurrentGameTime(db)).now;
const action = parseAction(message.payload.option?.action);
if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '응답할 수 없는 메시지입니다.' });
+40 -6
View File
@@ -1,6 +1,7 @@
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
export interface MessageView {
id: number;
@@ -59,15 +60,26 @@ const toMessageView = (row: MessageRow): MessageView => {
};
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
const gameTime = await loadCurrentGameTime(db);
const toTickOrNull = (date: Date): bigint | null => {
try {
const tick = gameTime.dateToTick(date);
return tick === null ? null : BigInt(tick);
} catch {
return null;
}
};
const rows = await db.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
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
@@ -87,12 +99,16 @@ export const fetchMessagesFromMailbox = async (params: {
fromSeq: number;
}): Promise<MessageView[]> => {
const fromSeq = Math.max(params.fromSeq, 0);
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE mailbox = ${params.mailbox}
AND type = ${params.msgType}
AND valid_until > NOW()
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)
AND id >= ${fromSeq}
ORDER BY id DESC
LIMIT ${params.limit}
@@ -108,12 +124,16 @@ export const fetchOldMessagesFromMailbox = async (params: {
toSeq: number;
limit: number;
}): Promise<MessageView[]> => {
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE mailbox = ${params.mailbox}
AND type = ${params.msgType}
AND valid_until > NOW()
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)
AND id < ${params.toSeq}
ORDER BY id DESC
LIMIT ${params.limit}
@@ -123,10 +143,15 @@ export const fetchOldMessagesFromMailbox = async (params: {
};
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db);
const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id} AND valid_until > NOW()
WHERE id = ${id}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)
LIMIT 1
`;
const row = rows[0];
@@ -141,10 +166,15 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
};
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db);
const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id} AND valid_until > NOW()
WHERE id = ${id}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)
LIMIT 1
FOR UPDATE
`;
@@ -162,8 +192,12 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Promise<void> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return;
const gameTime = await loadCurrentGameTime(db);
await db.message.updateMany({
where: { id: { in: uniqueIds } },
data: { validUntil: new Date() },
data: {
validUntil: gameTime.now,
...(gameTime.tick === null ? {} : { validUntilTick: BigInt(gameTime.tick) }),
},
});
};
+29 -23
View File
@@ -9,7 +9,8 @@ import { ItemLoader, isItemKey } from '@sammo-ts/logic';
import { asNumber, asRecord } from '@sammo-ts/common';
import { buildAuctionAlias } from '@sammo-ts/logic';
import { openAuctionWithDaemon } from '../../auction/open.js';
import { resolveAuctionTimerScore } from '../../auction/scheduler.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
const zBidInput = z.object({
auctionId: z.number().int().positive(),
@@ -99,10 +100,7 @@ const ensureAuctionSeasonActive = async (db: DatabaseClient): Promise<void> => {
}
};
const loadAuction = async (
db: DatabaseClient,
auctionId: number
): Promise<AuctionRow | null> => {
const loadAuction = async (db: DatabaseClient, auctionId: number): Promise<AuctionRow | null> => {
const rows = (await db.$queryRaw(
GamePrisma.sql`
SELECT id,
@@ -190,10 +188,7 @@ export const auctionRouter = router({
const [auctions, worldState, point, recentLogs] = await Promise.all([
ctx.db.auction.findMany({
where: {
OR: [
{ type: { in: ['BUY_RICE', 'SELL_RICE'] }, status: 'OPEN' },
{ type: 'UNIQUE_ITEM' },
],
OR: [{ type: { in: ['BUY_RICE', 'SELL_RICE'] }, status: 'OPEN' }, { type: 'UNIQUE_ITEM' }],
},
orderBy: [{ status: 'asc' }, { id: 'desc' }],
take: 120,
@@ -238,7 +233,7 @@ export const auctionRouter = router({
const hiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: worldState?.id ?? 0;
: (worldState?.id ?? 0);
const callerAlias = buildAuctionAlias(general.id, hiddenSeed, configConst);
const mapped = auctions.map((auction) => {
@@ -252,8 +247,8 @@ export const auctionRouter = router({
status: auction.status,
hostGeneralId: isUnique ? null : auction.hostGeneralId,
hostName: isUnique
? auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst)
: auction.hostName ?? names.get(auction.hostGeneralId) ?? '상인',
? (auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst))
: (auction.hostName ?? names.get(auction.hostGeneralId) ?? '상인'),
isCallerHost: auction.hostGeneralId === general.id,
closeAt: auction.closeAt.toISOString(),
detail,
@@ -262,7 +257,7 @@ export const auctionRouter = router({
amount: highestBid.amount,
bidderName: isUnique
? buildAuctionAlias(highestBid.generalId, hiddenSeed, configConst)
: names.get(highestBid.generalId) ?? '상인',
: (names.get(highestBid.generalId) ?? '상인'),
isCaller: highestBid.generalId === general.id,
eventAt: highestBid.eventAt.toISOString(),
}
@@ -305,14 +300,13 @@ export const auctionRouter = router({
const hiddenSeed =
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
? worldMeta.hiddenSeed
: worldState?.id ?? 0;
: (worldState?.id ?? 0);
return {
auction: {
id: auction.id,
targetCode: auction.targetCode,
status: auction.status,
hostName:
auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst),
hostName: auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst),
isCallerHost: auction.hostGeneralId === general.id,
closeAt: auction.closeAt.toISOString(),
detail: parseDetail(auction.detail),
@@ -362,7 +356,8 @@ export const auctionRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
const now = new Date();
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
@@ -418,7 +413,9 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
]);
return { ok: true };
}),
@@ -434,7 +431,8 @@ export const auctionRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
const now = new Date();
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
@@ -490,7 +488,9 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
]);
return { ok: true };
}),
@@ -506,7 +506,8 @@ export const auctionRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
const now = new Date();
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
@@ -586,7 +587,10 @@ export const auctionRouter = router({
}
const otherItem = await itemLoader.load(other.targetCode);
if (otherItem.slot === itemModule.slot) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '1순위 입찰자인 경매중에 같은 부위가 있습니다.' });
throw new TRPCError({
code: 'BAD_REQUEST',
message: '1순위 입찰자인 경매중에 같은 부위가 있습니다.',
});
}
}
}
@@ -620,7 +624,9 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
]);
return { ok: true };
}),
+5
View File
@@ -15,6 +15,7 @@ import {
} from '@sammo-ts/logic';
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import {
ConflictingTurnDaemonCommandError,
@@ -373,10 +374,12 @@ export const joinRouter = router({
message: 'World state is not initialized.',
});
}
const gameTime = await loadCurrentGameTime(ctx.db);
return reserveSelectionPool({
db: ctx.db,
worldState,
userId,
now: gameTime.now,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
});
}),
@@ -558,6 +561,7 @@ export const joinRouter = router({
});
}
try {
const gameTime = await loadCurrentGameTime(ctx.db);
return await reserveNpcPossessionCandidates({
db: ctx.db,
worldState,
@@ -565,6 +569,7 @@ export const joinRouter = router({
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
});
} catch (error) {
if (error instanceof NpcPossessionError) {
+136 -137
View File
@@ -26,6 +26,7 @@ import { publishRealtimeEvent } from '../../realtime/publisher.js';
import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js';
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -288,7 +289,8 @@ export const messagesRouter = router({
if (message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
}
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
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;
@@ -389,155 +391,152 @@ export const messagesRouter = router({
};
}),
send: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
text: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) {
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
text: z.string().min(1),
})
).mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '메시지 전송이 제한된 계정입니다.',
});
}
const src = await buildTargetFromGeneral(ctx.db, general);
const { now } = await loadCurrentGameTime(ctx.db);
const validUntil = new Date('9999-12-31T00:00:00Z');
let msgType: MessageType;
let dest = src;
let receiverMailbox = input.mailbox;
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '메시지 전송이 제한된 계정입니다.',
message: '공개 메세지를 보낼 수 없습니다.',
});
}
const src = await buildTargetFromGeneral(ctx.db, general);
const now = new Date();
const validUntil = new Date('9999-12-31T00:00:00Z');
let msgType: MessageType;
let dest = src;
let receiverMailbox = input.mailbox;
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '공개 메세지를 보낼 수 없습니다.',
});
}
msgType = 'public';
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
const sourceNation =
general.nationId > 0
? await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
const permission =
general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1;
const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
if (destNationId > 0) {
const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } });
if (!destNation) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '존재하지 않는 국가입니다.',
});
}
}
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
} else if (input.mailbox > 0) {
if (hasPenalty(general.penalty, 'noSendPrivateMsg')) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '개인 메세지를 보낼 수 없습니다.',
});
}
const intervalSeconds = Math.max(
0,
Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2))
);
if (intervalSeconds > 0) {
const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`;
const acquired = await ctx.redis.set(rateLimitKey, '1', {
NX: true,
PX: intervalSeconds * 1000,
});
if (acquired === null) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`,
});
}
}
const destGeneral = await ctx.db.general.findUnique({
where: { id: input.mailbox },
});
if (!destGeneral) {
msgType = 'public';
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
const sourceNation =
general.nationId > 0
? await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
const permission =
general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1;
const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
if (destNationId > 0) {
const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } });
if (!destNation) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '존재하지 않는 유저입니다.',
message: '존재하지 않는 국가입니다.',
});
}
const [sourceNation, destNation] = await Promise.all([
general.nationId > 0
? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } })
: null,
destGeneral.nationId > 0
? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } })
: null,
]);
const sourcePermission =
sourceNation && general.nationId > 0
? resolveNationPermission(general, sourceNation.meta, false)
: -1;
const destPermission =
destNation && destGeneral.nationId > 0
? resolveNationPermission(destGeneral, destNation.meta, false)
: -1;
if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
});
}
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
msgType = 'private';
} else {
}
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
} else if (input.mailbox > 0) {
if (hasPenalty(general.penalty, 'noSendPrivateMsg')) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid mailbox.',
code: 'FORBIDDEN',
message: '개인 메세지를 보낼 수 없습니다.',
});
}
const draft: MessageDraft = {
msgType,
src,
dest,
text: input.text,
time: now,
validUntil,
option: {},
};
const result = await sendMessage(
{
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
},
draft
const intervalSeconds = Math.max(
0,
Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2))
);
try {
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
type: 'messageCreated',
at: now.toISOString(),
mailbox: receiverMailbox,
msgType,
messageId: result.receiverId,
senderId: general.id,
if (intervalSeconds > 0) {
const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`;
const acquired = await ctx.redis.set(rateLimitKey, '1', {
NX: true,
PX: intervalSeconds * 1000,
});
} catch {
// 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다.
if (acquired === null) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`,
});
}
}
const destGeneral = await ctx.db.general.findUnique({
where: { id: input.mailbox },
});
if (!destGeneral) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '존재하지 않는 유저입니다.',
});
}
const [sourceNation, destNation] = await Promise.all([
general.nationId > 0
? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } })
: null,
destGeneral.nationId > 0
? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } })
: null,
]);
const sourcePermission =
sourceNation && general.nationId > 0 ? resolveNationPermission(general, sourceNation.meta, false) : -1;
const destPermission =
destNation && destGeneral.nationId > 0
? resolveNationPermission(destGeneral, destNation.meta, false)
: -1;
if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
});
}
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
msgType = 'private';
} else {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid mailbox.',
});
}
return { msgType, msgId: result.receiverId };
}),
const draft: MessageDraft = {
msgType,
src,
dest,
text: input.text,
time: now,
validUntil,
option: {},
};
const result = await sendMessage(
{
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
},
draft
);
try {
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
type: 'messageCreated',
at: now.toISOString(),
mailbox: receiverMailbox,
msgType,
messageId: result.receiverId,
senderId: general.id,
});
} catch {
// 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다.
}
return { msgType, msgId: result.receiverId };
}),
});
+5 -2
View File
@@ -9,6 +9,7 @@ import { TournamentStore } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
@@ -474,6 +475,7 @@ export const tournamentRouter = router({
await Promise.all([store.setParticipants([]), store.setMatches([]), store.setBettingEntries([])]);
const gameTime = await loadCurrentGameTime(ctx.db);
const nextState: TournamentState = {
...state,
stage: 0,
@@ -484,7 +486,7 @@ export const tournamentRouter = router({
rewardSettled: false,
bettingCloseAt: undefined,
participantsLockedAt: undefined,
nextAt: new Date().toISOString(),
nextAt: gameTime.now.toISOString(),
};
await store.setState(nextState);
return { ok: true };
@@ -506,7 +508,8 @@ export const tournamentRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
}
const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0;
if (closeAt && closeAt <= Date.now()) {
const gameNow = (await loadCurrentGameTime(ctx.db)).now.getTime();
if (closeAt && closeAt <= gameNow) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' });
}
+39 -9
View File
@@ -18,6 +18,7 @@ import {
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
@@ -133,9 +134,26 @@ type VotePollRow = {
opener_name: string;
start_at: Date;
end_at: Date | null;
end_tick: bigint | null;
closed_at: Date | null;
};
const hasPollEnded = (poll: Pick<VotePollRow, 'closed_at' | 'end_at' | 'end_tick'>, time: CurrentGameTime): boolean =>
Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null
? poll.end_tick <= BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at <= time.now));
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null;
try {
const tick = time.dateToTick(date);
return tick === null ? null : BigInt(tick);
} catch {
return null;
}
};
type VoteListRow = {
id: number;
title: string;
@@ -215,6 +233,7 @@ export const voteRouter = router({
opener_name,
start_at,
end_at,
end_tick,
closed_at
FROM vote_poll
WHERE id = ${input.voteId}
@@ -226,7 +245,8 @@ export const voteRouter = router({
}
const options = parseOptions(row.options);
const pollEnded = Boolean(row.closed_at) || (row.end_at ? row.end_at <= new Date() : false);
const gameTime = await loadCurrentGameTime(ctx.db);
const pollEnded = hasPollEnded(row, gameTime);
const userId = ctx.auth?.user.id;
const general = userId ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) : null;
@@ -330,6 +350,7 @@ export const voteRouter = router({
opener_name,
start_at,
end_at,
end_tick,
closed_at
FROM vote_poll
WHERE id = ${input.voteId}
@@ -339,7 +360,8 @@ export const voteRouter = router({
if (!poll) {
throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' });
}
if (poll.closed_at || (poll.end_at && poll.end_at < new Date())) {
const gameTime = await loadCurrentGameTime(ctx.db);
if (hasPollEnded(poll, gameTime)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '설문조사가 종료되었습니다.' });
}
@@ -536,7 +558,8 @@ export const voteRouter = router({
if (endAt && Number.isNaN(endAt.getTime())) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' });
}
if (endAt && endAt < new Date()) {
const gameTime = await loadCurrentGameTime(ctx.db);
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
@@ -551,7 +574,7 @@ export const voteRouter = router({
if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = NOW(), updated_at = NOW()
SET closed_at = ${gameTime.now}, updated_at = NOW()
WHERE closed_at IS NULL
`);
}
@@ -566,7 +589,9 @@ export const voteRouter = router({
opener_general_id,
opener_name,
start_at,
end_at
start_tick,
end_at,
end_tick
)
VALUES (
${input.title},
@@ -576,8 +601,10 @@ export const voteRouter = router({
${input.revealMode},
${general.id},
${general.name},
NOW(),
${endAt}
${gameTime.now},
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt},
${toGameTickOrNull(gameTime, endAt)}
)
`);
@@ -608,6 +635,7 @@ export const voteRouter = router({
opener_name,
start_at,
end_at,
end_tick,
closed_at
FROM vote_poll
WHERE id = ${input.voteId}
@@ -646,7 +674,8 @@ export const voteRouter = router({
if (endAt && Number.isNaN(endAt.getTime())) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' });
}
if (endAt && endAt < new Date()) {
const gameTime = await loadCurrentGameTime(ctx.db);
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
@@ -670,6 +699,7 @@ export const voteRouter = router({
multiple_options = COALESCE(${nextMultipleOptions}, multiple_options),
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = NOW()
WHERE id = ${input.voteId}
`);
@@ -681,7 +711,7 @@ export const voteRouter = router({
.mutation(async ({ ctx, input }) => {
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = NOW(), updated_at = NOW()
SET closed_at = ${(await loadCurrentGameTime(ctx.db)).now}, updated_at = NOW()
WHERE id = ${input.voteId}
RETURNING id
`);
+48
View File
@@ -0,0 +1,48 @@
import { GameClock, type GameClockMode } from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
export interface CurrentGameTime {
now: Date;
tick: number | null;
mode: GameClockMode | null;
dateToTick(date: Date): number | null;
}
export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date()): Promise<CurrentGameTime> => {
if (!db.worldState) {
return { now: wallNow, tick: null, mode: null, dateToTick: () => null };
}
const state = await db.worldState.findFirst({
orderBy: { id: 'asc' },
select: {
clockBaseTime: true,
clockTick: true,
clockMode: true,
clockWallAnchor: true,
tickSeconds: true,
},
});
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
return { now: wallNow, tick: null, mode: null, dateToTick: () => null };
}
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
const storedTick = Number(state.clockTick);
if (!Number.isSafeInteger(storedTick)) {
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`);
}
const clock = new GameClock({
baseTime: state.clockBaseTime,
tick: storedTick,
mode,
wallAnchor: state.clockWallAnchor,
turnSeconds: state.tickSeconds,
});
const tick = clock.nowTick(wallNow);
return {
now: clock.tickToDate(tick),
tick,
mode,
dateToTick: (date) => clock.dateToTick(date),
};
};
+11 -8
View File
@@ -11,6 +11,7 @@ import {
import { resolveGameApiConfigFromEnv } from '../config.js';
import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import type { TurnDaemonTransport } from '../daemon/transport.js';
import { buildTournamentKeys } from './keys.js';
@@ -161,7 +162,8 @@ export const applyPreBattleStage = async (
prisma: TournamentPrismaClient,
state: TournamentState,
baseSeed: string,
daemonTransport: TurnDaemonTransport
daemonTransport: TurnDaemonTransport,
now: () => number = Date.now
): Promise<TournamentState> => {
const participants = await store.getParticipants();
@@ -190,7 +192,7 @@ export const applyPreBattleStage = async (
...state,
stage: 2,
phase: 0,
participantsLockedAt: new Date().toISOString(),
participantsLockedAt: new Date(now()).toISOString(),
nextAt: resolveNextAt(state),
};
await store.setState(nextState);
@@ -418,7 +420,7 @@ export const applyPreBattleStage = async (
...state,
stage: 6,
phase: 0,
bettingId: state.bettingId ?? Date.now(),
bettingId: state.bettingId ?? now(),
bettingCloseAt: resolveBettingCloseAt(state),
nextAt: resolveNextAt(state),
};
@@ -430,7 +432,7 @@ export const applyPreBattleStage = async (
if (state.stage === 6) {
const bettingCloseAt = state.bettingCloseAt ?? resolveBettingCloseAt(state);
const bettingCloseMs = new Date(bettingCloseAt).getTime();
if (Number.isFinite(bettingCloseMs) && bettingCloseMs > Date.now()) {
if (Number.isFinite(bettingCloseMs) && bettingCloseMs > now()) {
const waitingState: TournamentState = {
...state,
bettingCloseAt,
@@ -578,7 +580,7 @@ export const processTournamentTick = async (options: {
if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport);
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
}
processedState =
(await settleTournamentOutcome({
@@ -620,9 +622,9 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
}
const nextAt = new Date(state.nextAt).getTime();
const now = Date.now();
if (state.auto && Number.isFinite(nextAt) && nextAt > now) {
await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - now));
const gameNow = (await loadCurrentGameTime(postgres.prisma)).now.getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > gameNow) {
await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - gameNow));
continue;
}
@@ -631,6 +633,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
store,
prisma: postgres.prisma,
daemonTransport,
now: () => gameNow,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
+28 -1
View File
@@ -14,7 +14,11 @@ const buildRedis = () => ({
const buildDb = (options: {
updated: number;
auction?: { status: 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED'; closeAt: Date } | null;
auction?: {
status: 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
closeAt: Date;
closeTick?: bigint | null;
} | null;
existingEvents?: Array<{
requestId: string;
target: 'ENGINE';
@@ -69,6 +73,29 @@ describe('auction worker clock-shift race', () => {
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
});
it('requeues a tick-backed auction using its logical deadline score', async () => {
const redis = buildRedis();
const closeAt = new Date('2099-01-01T00:00:00.000Z');
const { db } = buildDb({
updated: 0,
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
});
await expect(
processDueAuctionId({
db,
redis,
timerKey: 'timer',
historyKey: 'history',
id: '7',
nowMs: new Date('2042-01-01T00:00:00.000Z').getTime(),
nowTick: 36_000_000,
})
).resolves.toBe('RESCHEDULED');
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
});
it('commits the FINALIZING transition and durable command in one transaction before recording history', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
@@ -220,6 +220,25 @@ describe('tournament worker schedule compatibility', () => {
expect(resolveNextAt(state)).toBe('2026-08-02T10:10:00.000Z');
expect(resolveBettingCloseAt(state)).toBe('2026-08-02T11:00:00.000Z');
});
it('uses the injected game clock, not the host wall clock, to close betting', async () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('game-clock-deadline'));
const bettingCloseAt = '2099-01-01T00:00:00.000Z';
const state = createTournamentState({ stage: 6, bettingCloseAt, nextAt: bettingCloseAt });
await store.setState(state);
const next = await applyPreBattleStage(
store,
createPrismaMock({ baseSeed: 'clock-seed' }),
state,
'clock-seed',
createNoopDaemonTransport(),
() => new Date('2100-01-01T00:00:00.000Z').getTime()
);
expect(next).toMatchObject({ stage: 7, bettingCloseAt });
});
});
describe('tournament worker (in-memory)', () => {