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 {