feat: add logical game clock
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
|
||||
export interface AuctionTimerRow {
|
||||
id: number;
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
status: AuctionStatus;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '응답할 수 없는 메시지입니다.' });
|
||||
|
||||
@@ -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) }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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: '베팅이 마감되었습니다.' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user