merge: logical game clock
This commit is contained in:
@@ -51,6 +51,9 @@ GAME_API_PORT=14000
|
||||
PROFILE=hwe
|
||||
SCENARIO=default
|
||||
DAEMON_REQUEST_TIMEOUT_MS=5000
|
||||
# realtime은 벽시계 경과를 game tick으로 투영합니다. manual은 daemon이
|
||||
# 다음 월 snapshot을 대기 없이 실행하며 테스트/장기 시뮬레이션에 사용합니다.
|
||||
GAME_CLOCK_MODE=realtime
|
||||
TRPC_PATH=/trpc
|
||||
|
||||
# Frontend public URLs
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -192,7 +192,7 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '경매가 종료되었습니다.',
|
||||
};
|
||||
}
|
||||
const now = new Date();
|
||||
const now = world.getGameNow(new Date());
|
||||
if (auction.closeAt.getTime() <= now.getTime()) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
@@ -379,6 +379,7 @@ export const createAuctionBidder = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET close_at = ${nextCloseAt},
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
latest_event_id = ${eventId},
|
||||
latest_event_at = ${eventAt},
|
||||
updated_at = ${eventAt}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
ActionLogger,
|
||||
ItemLoader,
|
||||
LogFormat,
|
||||
UserLogger,
|
||||
isItemKey,
|
||||
resolveUniqueConfig,
|
||||
} from '@sammo-ts/logic';
|
||||
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey, resolveUniqueConfig } from '@sammo-ts/logic';
|
||||
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
|
||||
@@ -180,7 +173,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
const highestBid = bidRows[0] ?? null;
|
||||
|
||||
const now = new Date();
|
||||
const now = world.getGameNow(new Date());
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const globalLogger = new ActionLogger();
|
||||
|
||||
@@ -244,9 +237,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
const remainExtension = detail.remainCloseDateExtensionCnt ?? 0;
|
||||
if (bidMeta.tryExtendCloseDate === true && remainExtension > 0) {
|
||||
const turnMinutes = await resolveTurnMinutes(db);
|
||||
const nextCloseAt = new Date(
|
||||
auction.closeAt.getTime() + Math.max(5, turnMinutes) * 60_000
|
||||
);
|
||||
const nextCloseAt = new Date(auction.closeAt.getTime() + Math.max(5, turnMinutes) * 60_000);
|
||||
const nextLatestBidCloseAt = new Date(
|
||||
nextCloseAt.getTime() +
|
||||
Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) *
|
||||
@@ -263,6 +254,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
SET status = 'OPEN',
|
||||
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||
close_at = ${nextCloseAt},
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
`
|
||||
@@ -419,6 +411,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
|
||||
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||
close_at = ${nextCloseAt},
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
`
|
||||
@@ -449,10 +442,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
const nextLatestBidCloseAt = new Date(
|
||||
nextCloseAt.getTime() +
|
||||
Math.max(
|
||||
MIN_EXTENSION_MINUTES_PER_BID,
|
||||
turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID
|
||||
) *
|
||||
Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) *
|
||||
60_000
|
||||
);
|
||||
const nextDetail = {
|
||||
@@ -467,6 +457,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
|
||||
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||
close_at = ${nextCloseAt},
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
`
|
||||
|
||||
@@ -94,7 +94,7 @@ const openResourceAuction = async (
|
||||
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const now = world.getGameNow(new Date());
|
||||
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
|
||||
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
|
||||
const auction = await db.auction.create({
|
||||
@@ -113,6 +113,8 @@ const openResourceAuction = async (
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
openTick: BigInt(world.dateToGameTick(now)),
|
||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||
},
|
||||
});
|
||||
world.updateGeneral(general.id, {
|
||||
@@ -218,7 +220,7 @@ const openUniqueAuction = async (
|
||||
|
||||
const state = world.getState();
|
||||
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
|
||||
const now = new Date();
|
||||
const now = world.getGameNow(new Date());
|
||||
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
|
||||
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
|
||||
const extensionLimitMinutes = Math.max(
|
||||
@@ -250,6 +252,8 @@ const openUniqueAuction = async (
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
openTick: BigInt(world.dateToGameTick(now)),
|
||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||
latestEventId: eventId,
|
||||
latestEventAt: now,
|
||||
bids: {
|
||||
|
||||
@@ -165,17 +165,33 @@ export class TurnDaemonLifecycle {
|
||||
}
|
||||
|
||||
const nowMs = this.clock.nowMs();
|
||||
const gameClock = await this.stateStore.loadGameClock?.(new Date(nowMs));
|
||||
if (gameClock?.mode === 'manual') {
|
||||
// Ref observes all generals due before one monthly boundary in
|
||||
// a single snapshot. Manual mode advances directly to that
|
||||
// boundary instead of letting sub-minute general timestamps
|
||||
// alter command/RNG order. After a restart, drain only turns
|
||||
// strictly older than the persisted game time before moving on.
|
||||
const gameNowMs = gameClock.now.getTime();
|
||||
const hasOverdueGeneral = nextRunTime.getTime() < gameNowMs;
|
||||
const targetTime = hasOverdueGeneral
|
||||
? new Date(gameNowMs - 1)
|
||||
: this.getNextTickTime(new Date(this.status.lastTurnTime!));
|
||||
await this.runOnce({ reason: 'schedule', targetTime });
|
||||
continue;
|
||||
}
|
||||
const gameNowMs = gameClock?.now.getTime() ?? nowMs;
|
||||
const nextTurnMs = nextRunTime.getTime();
|
||||
if (nowMs >= nextTurnMs) {
|
||||
if (gameNowMs >= nextTurnMs) {
|
||||
// Ref checkDelay() executes every turn due at the observed
|
||||
// wall-clock time in one snapshot. Using only the oldest due
|
||||
// timestamp lets generals created by that batch run before a
|
||||
// monthly boundary, although Ref defers them to the next pass.
|
||||
await this.runOnce({ reason: 'schedule', targetTime: new Date(nowMs) });
|
||||
await this.runOnce({ reason: 'schedule', targetTime: new Date(gameNowMs) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const command = await this.controlQueue.waitUntil(nextTurnMs);
|
||||
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs));
|
||||
if (command) {
|
||||
await this.handleCommand(command);
|
||||
}
|
||||
@@ -354,6 +370,7 @@ export class TurnDaemonLifecycle {
|
||||
|
||||
try {
|
||||
const runAndFlush = async (): Promise<TurnRunResult> => {
|
||||
await this.stateStore.advanceGameClockTo?.(targetTime, new Date(startMs));
|
||||
const nextResult = await this.processor.run(targetTime, budget, checkpoint);
|
||||
fallbackError = 'Unknown turn flush error.';
|
||||
this.status.state = 'flushing';
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
TurnRunResult,
|
||||
} from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GameClockMode } from '@sammo-ts/common';
|
||||
|
||||
export type {
|
||||
RunReason,
|
||||
@@ -51,6 +52,8 @@ export interface TurnStateStore {
|
||||
saveLastTurnTime(turnTime: Date): Promise<void>;
|
||||
loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
|
||||
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
|
||||
loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>;
|
||||
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TurnDaemonControlQueue {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type InputJsonValue,
|
||||
type TurnEngineEventCreateManyInput,
|
||||
} from '@sammo-ts/infra';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common';
|
||||
import {
|
||||
buildScenarioBootstrap,
|
||||
resolveScenarioGeneralDeathMonth,
|
||||
@@ -66,6 +66,7 @@ export interface ScenarioSeedOptions {
|
||||
resetTables?: boolean;
|
||||
now?: Date;
|
||||
tickSeconds?: number;
|
||||
gameClockMode?: GameClockMode;
|
||||
installOptions?: ScenarioInstallOptions;
|
||||
includeNeutralNationInSeed?: boolean;
|
||||
defaultGeneralGold?: number;
|
||||
@@ -217,6 +218,15 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||
const sync = install?.sync ?? false;
|
||||
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
||||
const gameClockMode = options.gameClockMode ?? 'realtime';
|
||||
const initialClock = new GameClock({
|
||||
baseTime: startState.startTime,
|
||||
tick: 0,
|
||||
mode: gameClockMode,
|
||||
wallAnchor: now,
|
||||
turnSeconds: tickSeconds,
|
||||
});
|
||||
const initialClockTick = initialClock.dateToTick(now);
|
||||
|
||||
const { seed, warnings } = buildScenarioBootstrap({
|
||||
scenario: scenarioDefinition,
|
||||
@@ -367,6 +377,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
currentYear: startState.currentYear,
|
||||
currentMonth: startState.currentMonth,
|
||||
tickSeconds,
|
||||
clockBaseTime: initialClock.baseTime,
|
||||
clockTick: BigInt(initialClockTick),
|
||||
clockMode: gameClockMode,
|
||||
clockWallAnchor: now,
|
||||
lastTurnTick: BigInt(initialClockTick),
|
||||
config: asJson({ ...scenarioConfig, ...worldConfig }),
|
||||
meta: asJson(worldMeta),
|
||||
},
|
||||
@@ -523,6 +538,18 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
: 0) / 1_000
|
||||
)
|
||||
),
|
||||
turnTick: BigInt(
|
||||
initialClock.dateToTick(
|
||||
new Date(
|
||||
now.getTime() +
|
||||
Math.floor(
|
||||
(typeof general.meta.initialTurnOffsetMicros === 'number'
|
||||
? general.meta.initialTurnOffsetMicros
|
||||
: 0) / 1_000
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
age: resolveGeneralAge(startState.currentYear, general.birthYear),
|
||||
// Legacy GeneralBuilder leaves startage at the schema default on install.
|
||||
startAge: 20,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
import { parseOptionalBoolean, parseOptionalNumber } from '@sammo-ts/common';
|
||||
import { parseOptionalBoolean, parseOptionalNumber, type GameClockMode } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnRunBudget } from '../lifecycle/types.js';
|
||||
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
|
||||
@@ -16,6 +16,7 @@ export interface TurnDaemonCliOptions {
|
||||
budget?: Partial<TurnRunBudget>;
|
||||
enableDatabaseFlush?: boolean;
|
||||
adminActionIntervalMs?: number;
|
||||
gameClockMode?: GameClockMode;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
@@ -58,6 +59,11 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
|
||||
const enableDatabaseFlush = options.enableDatabaseFlush ?? parseOptionalBoolean(env.TURN_FLUSH_DB) ?? true;
|
||||
const pauseGateIntervalMs = parseOptionalNumber(env.TURN_PAUSE_GATE_MS);
|
||||
const adminActionIntervalMs = options.adminActionIntervalMs ?? parseOptionalNumber(env.TURN_ADMIN_ACTION_MS);
|
||||
const rawGameClockMode = options.gameClockMode ?? env.GAME_CLOCK_MODE;
|
||||
if (rawGameClockMode && rawGameClockMode !== 'realtime' && rawGameClockMode !== 'manual') {
|
||||
throw new Error(`GAME_CLOCK_MODE must be realtime or manual: ${rawGameClockMode}`);
|
||||
}
|
||||
const gameClockMode = rawGameClockMode as GameClockMode | undefined;
|
||||
|
||||
const runtime = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
@@ -70,6 +76,7 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
|
||||
enableDatabaseFlush,
|
||||
pauseGateIntervalMs,
|
||||
adminActionIntervalMs,
|
||||
gameClockMode,
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
|
||||
@@ -374,7 +374,10 @@ const buildGeneralUpdate = (
|
||||
penalty: asJson(general.penalty ?? {}),
|
||||
meta: buildPersistedGeneralMeta(general),
|
||||
turnTime: general.turnTime,
|
||||
turnTick: BigInt(general.turnTick ?? 0),
|
||||
recentWarTime: general.recentWarTime ?? null,
|
||||
recentWarTick:
|
||||
general.recentWarTick === null || general.recentWarTick === undefined ? null : BigInt(general.recentWarTick),
|
||||
});
|
||||
|
||||
const buildGeneralCreate = (
|
||||
@@ -418,7 +421,10 @@ const buildGeneralCreate = (
|
||||
penalty: asJson(general.penalty ?? {}),
|
||||
meta: buildPersistedGeneralMeta(general),
|
||||
turnTime: general.turnTime,
|
||||
turnTick: BigInt(general.turnTick ?? 0),
|
||||
recentWarTime: general.recentWarTime ?? null,
|
||||
recentWarTick:
|
||||
general.recentWarTick === null || general.recentWarTick === undefined ? null : BigInt(general.recentWarTick),
|
||||
});
|
||||
|
||||
const buildCityUpdate = (
|
||||
@@ -601,6 +607,11 @@ export const createDatabaseTurnHooks = async (
|
||||
currentYear: state.currentYear,
|
||||
currentMonth: state.currentMonth,
|
||||
tickSeconds: state.tickSeconds,
|
||||
clockBaseTime: state.clockBaseTime ?? state.lastTurnTime,
|
||||
clockTick: BigInt(state.clockTick ?? 0),
|
||||
clockMode: state.clockMode ?? 'manual',
|
||||
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
|
||||
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
|
||||
meta: asJson(state.meta),
|
||||
};
|
||||
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
|
||||
@@ -649,7 +660,8 @@ export const createDatabaseTurnHooks = async (
|
||||
prisma,
|
||||
lifecycleEvents,
|
||||
meta,
|
||||
asRecord(world.getScenarioConfig().const)
|
||||
asRecord(world.getScenarioConfig().const),
|
||||
world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0)
|
||||
);
|
||||
|
||||
if (inheritancePointAdjustments.length > 0) {
|
||||
@@ -732,6 +744,8 @@ export const createDatabaseTurnHooks = async (
|
||||
hostName: auction.hostName,
|
||||
detail: asJson(auction.detail),
|
||||
status: 'OPEN',
|
||||
openTick: BigInt(state.clockTick ?? world.dateToGameTick(state.lastTurnTime)),
|
||||
closeTick: BigInt(world.dateToGameTick(auction.closeAt)),
|
||||
closeAt: auction.closeAt,
|
||||
})),
|
||||
});
|
||||
@@ -959,15 +973,29 @@ export const createDatabaseTurnHooks = async (
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: async (draft: MessageRecordDraft) => {
|
||||
const toTickOrNull = (date: Date): bigint | null => {
|
||||
try {
|
||||
return BigInt(world.dateToGameTick(date));
|
||||
} catch {
|
||||
// Legacy messages may use year 9999 as an
|
||||
// effectively-unbounded expiry, beyond the
|
||||
// safe JavaScript tick range.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, 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
|
||||
|
||||
@@ -157,7 +157,8 @@ const computeRate = (numerator: number, denominator: number): number => (denomin
|
||||
const settleHall = async (
|
||||
prisma: GamePrisma.TransactionClient,
|
||||
event: GeneralLifecycleEvent,
|
||||
worldMeta: Record<string, unknown>
|
||||
worldMeta: Record<string, unknown>,
|
||||
gameNow: Date
|
||||
): Promise<void> => {
|
||||
const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0));
|
||||
if (isUnited !== 0) {
|
||||
@@ -207,7 +208,7 @@ const settleHall = async (
|
||||
bgColor: nation?.color ?? '#000000',
|
||||
fgColor: resolveLegacyTextColor(nation?.color ?? '#000000'),
|
||||
startTime: typeof worldMeta.starttime === 'string' ? worldMeta.starttime : null,
|
||||
unitedTime: new Date().toISOString(),
|
||||
unitedTime: gameNow.toISOString(),
|
||||
ownerDisplayName:
|
||||
typeof asRecord(event.before.meta).ownerDisplayName === 'string'
|
||||
? asRecord(event.before.meta).ownerDisplayName
|
||||
@@ -329,7 +330,8 @@ export const persistGeneralLifecycleEvents = async (
|
||||
prisma: GamePrisma.TransactionClient,
|
||||
events: GeneralLifecycleEvent[],
|
||||
worldMeta: Record<string, unknown>,
|
||||
configConst: Record<string, unknown>
|
||||
configConst: Record<string, unknown>,
|
||||
gameNow = new Date()
|
||||
): Promise<void> => {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
@@ -348,7 +350,7 @@ export const persistGeneralLifecycleEvents = async (
|
||||
await settleInheritance(prisma, event, worldMeta, false, configConst);
|
||||
}
|
||||
if (event.outcome === 'retired') {
|
||||
await settleHall(prisma, event, worldMeta);
|
||||
await settleHall(prisma, event, worldMeta, gameNow);
|
||||
await settleInheritance(prisma, event, worldMeta, true, configConst);
|
||||
await prisma.rankData.updateMany({
|
||||
where: { generalId: event.generalId },
|
||||
|
||||
@@ -28,4 +28,15 @@ export class InMemoryTurnStateStore implements TurnStateStore {
|
||||
async saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void> {
|
||||
this.world.setCheckpoint(checkpoint);
|
||||
}
|
||||
|
||||
async loadGameClock(wallNow = new Date(Date.now())): Promise<{ mode: 'realtime' | 'manual'; now: Date }> {
|
||||
return {
|
||||
mode: this.world.getGameClockState().mode,
|
||||
now: this.world.getGameNow(wallNow),
|
||||
};
|
||||
}
|
||||
|
||||
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
|
||||
this.world.advanceGameClockTo(target, wallNow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +62,12 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
// Ref processes `turntime < monthlyBoundary` before the monthly turn. A
|
||||
// general exactly on the boundary therefore runs only after that month
|
||||
// has advanced, on the daemon's following pass.
|
||||
const useStrictGeneralCutoff =
|
||||
firstTickTime.getTime() === targetTime.getTime() || targetTime.getTime() <= previousLastTurnTime.getTime();
|
||||
const generalCutoff =
|
||||
useStrictGeneralCutoff
|
||||
? new Date(targetTime.getTime() - 1)
|
||||
: targetTime;
|
||||
// The monthly boundary itself stays strict (`turn_time < boundary`) like
|
||||
// Ref. A manual clock may instead target an overdue general whose time
|
||||
// is older than lastTurnTime; that exact general must be included or the
|
||||
// daemon would repeatedly flush an empty run without advancing.
|
||||
const useStrictGeneralCutoff = firstTickTime.getTime() === targetTime.getTime();
|
||||
const generalCutoff = useStrictGeneralCutoff ? new Date(targetTime.getTime() - 1) : targetTime;
|
||||
const dueGenerals = this.world.listDueGenerals(generalCutoff, checkpoint);
|
||||
for (const general of dueGenerals) {
|
||||
if (processedGenerals >= budget.maxGenerals || isBudgetExpired()) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import { getNextTurnAt } from '@sammo-ts/logic';
|
||||
import { GameClock, type GameClockMode } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||
import type {
|
||||
@@ -105,6 +106,14 @@ export interface InMemoryTurnWorldOptions {
|
||||
autoAdvanceDiplomacyMonth?: boolean;
|
||||
}
|
||||
|
||||
export interface InMemoryGameClockState {
|
||||
baseTime: Date;
|
||||
tick: number;
|
||||
mode: GameClockMode;
|
||||
wallAnchor: Date;
|
||||
lastTurnTick: number;
|
||||
}
|
||||
|
||||
export interface TurnWorldChanges {
|
||||
generals: TurnGeneral[];
|
||||
cities: City[];
|
||||
@@ -421,7 +430,36 @@ export class InMemoryTurnWorld {
|
||||
private state: TurnWorldState;
|
||||
|
||||
constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) {
|
||||
this.state = { ...state };
|
||||
const baseTime = new Date((state.clockBaseTime ?? state.lastTurnTime).getTime());
|
||||
const mode = state.clockMode ?? 'manual';
|
||||
const wallAnchor = new Date((state.clockWallAnchor ?? state.lastTurnTime).getTime());
|
||||
const bootstrapClock = new GameClock({
|
||||
baseTime,
|
||||
tick: state.clockTick ?? 0,
|
||||
mode,
|
||||
wallAnchor,
|
||||
turnSeconds: state.tickSeconds,
|
||||
});
|
||||
const lastTurnTick = state.lastTurnTick ?? bootstrapClock.dateToTick(state.lastTurnTime);
|
||||
const clockTick = state.clockTick ?? lastTurnTick;
|
||||
const gameClock = new GameClock({
|
||||
baseTime,
|
||||
tick: clockTick,
|
||||
mode,
|
||||
wallAnchor,
|
||||
turnSeconds: state.tickSeconds,
|
||||
});
|
||||
const lastTurnTime = gameClock.tickToDate(lastTurnTick);
|
||||
this.state = {
|
||||
...state,
|
||||
clockBaseTime: baseTime,
|
||||
clockTick,
|
||||
clockMode: mode,
|
||||
clockWallAnchor: wallAnchor,
|
||||
lastTurnTick,
|
||||
lastTurnTime,
|
||||
meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() },
|
||||
};
|
||||
this.scenarioConfig = snapshot.scenarioConfig;
|
||||
this.unitSet = snapshot.unitSet;
|
||||
this.schedule = options.schedule;
|
||||
@@ -435,7 +473,9 @@ export class InMemoryTurnWorld {
|
||||
|
||||
const worldKillturn = resolveWorldKillturn(this.state.meta);
|
||||
for (const general of snapshot.generals) {
|
||||
const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime);
|
||||
const normalized = this.normalizeGeneralClock(
|
||||
normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime)
|
||||
);
|
||||
const ensured = ensureGeneralKillturn(normalized, worldKillturn);
|
||||
this.generals.set(general.id, ensured);
|
||||
}
|
||||
@@ -461,6 +501,67 @@ export class InMemoryTurnWorld {
|
||||
this.ensureDiplomacyMatrix();
|
||||
}
|
||||
|
||||
private getGameClock(): GameClock {
|
||||
return new GameClock({
|
||||
baseTime: this.state.clockBaseTime ?? this.state.lastTurnTime,
|
||||
tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0,
|
||||
mode: this.state.clockMode ?? 'manual',
|
||||
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
|
||||
turnSeconds: this.state.tickSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeGeneralClock(general: TurnGeneral): TurnGeneral {
|
||||
const clock = this.getGameClock();
|
||||
const turnTick = general.turnTick ?? clock.dateToTick(general.turnTime);
|
||||
const recentWarTick =
|
||||
general.recentWarTick !== undefined
|
||||
? general.recentWarTick
|
||||
: general.recentWarTime
|
||||
? clock.dateToTick(general.recentWarTime)
|
||||
: null;
|
||||
return {
|
||||
...general,
|
||||
turnTick,
|
||||
turnTime: clock.tickToDate(turnTick),
|
||||
recentWarTick,
|
||||
recentWarTime: recentWarTick === null ? null : clock.tickToDate(recentWarTick),
|
||||
};
|
||||
}
|
||||
|
||||
getGameClockState(): InMemoryGameClockState {
|
||||
return {
|
||||
baseTime: new Date((this.state.clockBaseTime ?? this.state.lastTurnTime).getTime()),
|
||||
tick: this.state.clockTick ?? 0,
|
||||
mode: this.state.clockMode ?? 'manual',
|
||||
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
|
||||
lastTurnTick: this.state.lastTurnTick ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
getGameNow(wallNow: Date): Date {
|
||||
return this.getGameClock().now(wallNow);
|
||||
}
|
||||
|
||||
dateToGameTick(date: Date): number {
|
||||
return this.getGameClock().dateToTick(date);
|
||||
}
|
||||
|
||||
gameTickToDate(tick: number): Date {
|
||||
return this.getGameClock().tickToDate(tick);
|
||||
}
|
||||
|
||||
advanceGameClockTo(target: Date, wallNow: Date): void {
|
||||
const clock = this.getGameClock();
|
||||
const targetTick = clock.dateToTick(target);
|
||||
const nextTick = Math.max(clock.tick, targetTick);
|
||||
this.state = {
|
||||
...this.state,
|
||||
clockTick: nextTick,
|
||||
clockWallAnchor: new Date(wallNow.getTime()),
|
||||
};
|
||||
}
|
||||
|
||||
captureState(): InMemoryTurnWorldStateSnapshot {
|
||||
return structuredClone({
|
||||
schedule: this.schedule,
|
||||
@@ -573,17 +674,31 @@ export class InMemoryTurnWorld {
|
||||
if (previousTickSeconds === nextTickSeconds) {
|
||||
return;
|
||||
}
|
||||
const previousClock = this.getGameClock();
|
||||
const anchorTick = this.state.clockTick ?? previousClock.tick;
|
||||
const anchorDisplay = previousClock.tickToDate(anchorTick);
|
||||
const nextBaseTime = GameClock.baseTimeForProjection(anchorDisplay, anchorTick, nextTickSeconds);
|
||||
const ratio = nextTickSeconds / previousTickSeconds;
|
||||
const baseTime = this.state.lastTurnTime.getTime();
|
||||
for (const general of this.generals.values()) {
|
||||
const nextTurnTime = new Date(baseTime + (general.turnTime.getTime() - baseTime) * ratio);
|
||||
this.updateGeneral(general.id, { turnTime: nextTurnTime });
|
||||
}
|
||||
const nextGeneralTimes = new Map(
|
||||
Array.from(this.generals.values(), (general) => [
|
||||
general.id,
|
||||
new Date(baseTime + (general.turnTime.getTime() - baseTime) * ratio),
|
||||
])
|
||||
);
|
||||
this.schedule = { entries: [{ startMinute: 0, tickMinutes }] };
|
||||
this.state = {
|
||||
...this.state,
|
||||
tickSeconds: nextTickSeconds,
|
||||
clockBaseTime: nextBaseTime,
|
||||
};
|
||||
for (const general of this.generals.values()) {
|
||||
const nextTurnTime = nextGeneralTimes.get(general.id);
|
||||
if (!nextTurnTime) {
|
||||
throw new Error(`Missing projected turn time for general ${general.id}.`);
|
||||
}
|
||||
this.updateGeneral(general.id, { turnTime: nextTurnTime });
|
||||
}
|
||||
}
|
||||
|
||||
pushLog(entry: LogEntryDraft): void {
|
||||
@@ -739,7 +854,15 @@ export class InMemoryTurnWorld {
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const next = applyGeneralPatch(target, patch);
|
||||
const next = this.normalizeGeneralClock(
|
||||
applyGeneralPatch(target, {
|
||||
...patch,
|
||||
...(patch.turnTime && patch.turnTick === undefined ? { turnTick: undefined } : {}),
|
||||
...(patch.recentWarTime !== undefined && patch.recentWarTick === undefined
|
||||
? { recentWarTick: undefined }
|
||||
: {}),
|
||||
})
|
||||
);
|
||||
this.generals.set(id, next);
|
||||
this.dirtyGeneralIds.add(id);
|
||||
return next;
|
||||
@@ -750,7 +873,9 @@ export class InMemoryTurnWorld {
|
||||
return false;
|
||||
}
|
||||
const worldKillturn = resolveWorldKillturn(this.state.meta);
|
||||
const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime);
|
||||
const normalized = this.normalizeGeneralClock(
|
||||
normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime)
|
||||
);
|
||||
const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn));
|
||||
this.generals.set(general.id, ensured);
|
||||
this.dirtyGeneralIds.add(general.id);
|
||||
@@ -882,18 +1007,23 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
|
||||
setLastTurnTime(turnTime: Date): void {
|
||||
const clock = this.getGameClock();
|
||||
const requestedTick = clock.dateToTick(turnTime);
|
||||
const lastTurnTick = Math.max(this.state.lastTurnTick ?? requestedTick, requestedTick);
|
||||
const projectedTime = clock.tickToDate(lastTurnTick);
|
||||
const meta = {
|
||||
...this.state.meta,
|
||||
lastTurnTime: turnTime.toISOString(),
|
||||
lastTurnTime: projectedTime.toISOString(),
|
||||
};
|
||||
this.state = {
|
||||
...this.state,
|
||||
lastTurnTime: new Date(turnTime.getTime()),
|
||||
lastTurnTick,
|
||||
lastTurnTime: projectedTime,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
shiftSchedule(deltaMinutes: number): { shiftedGenerals: number; lastTurnTime: string } {
|
||||
shiftSchedule(deltaMinutes: number, wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } {
|
||||
if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) {
|
||||
throw new Error('Schedule shift must be a non-zero integer number of minutes.');
|
||||
}
|
||||
@@ -931,7 +1061,27 @@ export class InMemoryTurnWorld {
|
||||
);
|
||||
};
|
||||
|
||||
const nextLastTurnTime = shiftDate(this.state.lastTurnTime);
|
||||
const previousClock = this.getGameClock();
|
||||
const generalTicks = new Map(
|
||||
Array.from(this.generals.values(), (general) => [
|
||||
general.id,
|
||||
{
|
||||
turnTick: general.turnTick ?? previousClock.dateToTick(general.turnTime),
|
||||
recentWarTick:
|
||||
general.recentWarTick ??
|
||||
(general.recentWarTime ? previousClock.dateToTick(general.recentWarTime) : null),
|
||||
},
|
||||
])
|
||||
);
|
||||
const nextBaseTime = shiftDate(previousClock.baseTime);
|
||||
const shiftedClock = new GameClock({
|
||||
baseTime: nextBaseTime,
|
||||
tick: this.state.clockTick ?? 0,
|
||||
mode: this.state.clockMode ?? 'manual',
|
||||
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
|
||||
turnSeconds: this.state.tickSeconds,
|
||||
});
|
||||
const nextLastTurnTime = shiftedClock.tickToDate(this.state.lastTurnTick ?? 0);
|
||||
const nextMeta = {
|
||||
...this.state.meta,
|
||||
lastTurnTime: nextLastTurnTime.toISOString(),
|
||||
@@ -941,12 +1091,27 @@ export class InMemoryTurnWorld {
|
||||
};
|
||||
this.state = {
|
||||
...this.state,
|
||||
clockBaseTime: nextBaseTime,
|
||||
// Rebasing is also the explicit resume checkpoint. Realtime mode
|
||||
// must not replay the operational downtime after an administrator
|
||||
// deliberately delays or accelerates the game schedule.
|
||||
clockWallAnchor: new Date(wallNow.getTime()),
|
||||
lastTurnTime: nextLastTurnTime,
|
||||
meta: nextMeta,
|
||||
};
|
||||
|
||||
for (const general of this.generals.values()) {
|
||||
this.updateGeneral(general.id, { turnTime: shiftDate(general.turnTime) });
|
||||
const ticks = generalTicks.get(general.id);
|
||||
if (!ticks) {
|
||||
throw new Error(`Missing captured game ticks for general ${general.id}.`);
|
||||
}
|
||||
const { turnTick, recentWarTick } = ticks;
|
||||
this.updateGeneral(general.id, {
|
||||
turnTick,
|
||||
turnTime: shiftedClock.tickToDate(turnTick),
|
||||
recentWarTick,
|
||||
recentWarTime: recentWarTick === null ? null : shiftedClock.tickToDate(recentWarTick),
|
||||
});
|
||||
}
|
||||
for (const auction of this.pendingNeutralAuctions) {
|
||||
auction.closeAt = shiftDate(auction.closeAt);
|
||||
@@ -1055,10 +1220,13 @@ export class InMemoryTurnWorld {
|
||||
|
||||
const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
|
||||
if (!result.deleted?.general) {
|
||||
const nextGeneral = normalizeGeneralDatabaseIntegers({
|
||||
...(result.general ?? currentGeneral),
|
||||
turnTime: nextTurnAt,
|
||||
});
|
||||
const nextGeneral = this.normalizeGeneralClock(
|
||||
normalizeGeneralDatabaseIntegers({
|
||||
...(result.general ?? currentGeneral),
|
||||
turnTime: nextTurnAt,
|
||||
turnTick: undefined,
|
||||
})
|
||||
);
|
||||
this.generals.set(nextGeneral.id, nextGeneral);
|
||||
this.dirtyGeneralIds.add(nextGeneral.id);
|
||||
}
|
||||
@@ -1083,8 +1251,17 @@ export class InMemoryTurnWorld {
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
const patched = applyGeneralPatch(target, patch.patch);
|
||||
this.generals.set(patch.id, normalizeGeneralTurnTime(patched, this.state.lastTurnTime));
|
||||
const patched = applyGeneralPatch(target, {
|
||||
...patch.patch,
|
||||
...(patch.patch.turnTime && patch.patch.turnTick === undefined ? { turnTick: undefined } : {}),
|
||||
...(patch.patch.recentWarTime !== undefined && patch.patch.recentWarTick === undefined
|
||||
? { recentWarTick: undefined }
|
||||
: {}),
|
||||
});
|
||||
this.generals.set(
|
||||
patch.id,
|
||||
this.normalizeGeneralClock(normalizeGeneralTurnTime(patched, this.state.lastTurnTime))
|
||||
);
|
||||
this.dirtyGeneralIds.add(patch.id);
|
||||
}
|
||||
for (const patch of result.patches.cities) {
|
||||
@@ -1127,7 +1304,9 @@ export class InMemoryTurnWorld {
|
||||
continue;
|
||||
}
|
||||
const worldKillturn = resolveWorldKillturn(this.state.meta);
|
||||
const normalized = normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime);
|
||||
const normalized = this.normalizeGeneralClock(
|
||||
normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime)
|
||||
);
|
||||
const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn));
|
||||
this.generals.set(createdGeneral.id, ensured);
|
||||
this.dirtyGeneralIds.add(createdGeneral.id);
|
||||
@@ -1195,15 +1374,20 @@ export class InMemoryTurnWorld {
|
||||
};
|
||||
await this.calendarHandler?.beforeMonthChanged?.(context);
|
||||
|
||||
const clock = this.getGameClock();
|
||||
const requestedTick = clock.dateToTick(turnTime);
|
||||
const lastTurnTick = Math.max(this.state.lastTurnTick ?? requestedTick, requestedTick);
|
||||
const lastTurnTime = clock.tickToDate(lastTurnTick);
|
||||
const meta = {
|
||||
...this.state.meta,
|
||||
lastTurnTime: turnTime.toISOString(),
|
||||
lastTurnTime: lastTurnTime.toISOString(),
|
||||
};
|
||||
this.state = {
|
||||
...this.state,
|
||||
currentYear: nextYear,
|
||||
currentMonth: nextMonth,
|
||||
lastTurnTime: new Date(turnTime.getTime()),
|
||||
lastTurnTick,
|
||||
lastTurnTime,
|
||||
meta,
|
||||
};
|
||||
|
||||
|
||||
@@ -62,9 +62,7 @@ export const createOpenNationBettingHandler = (options: {
|
||||
|
||||
const currentLastId = world.getState().meta.lastBettingId;
|
||||
const bettingId =
|
||||
(typeof currentLastId === 'number' && Number.isFinite(currentLastId)
|
||||
? Math.trunc(currentLastId)
|
||||
: 0) + 1;
|
||||
(typeof currentLastId === 'number' && Number.isFinite(currentLastId) ? Math.trunc(currentLastId) : 0) + 1;
|
||||
world.updateWorldMeta({ lastBettingId: bettingId });
|
||||
|
||||
const shortName = nationCount === 1 ? '천통국' : `최후 ${nationCount}국`;
|
||||
@@ -88,10 +86,7 @@ export const createOpenNationBettingHandler = (options: {
|
||||
targetCode: 'DESTROY_NATION',
|
||||
priority: 1_000,
|
||||
condition: ['RemainNation', '<=', nationCount],
|
||||
action: [
|
||||
['FinishNationBetting', bettingId],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
action: [['FinishNationBetting', bettingId], ['DeleteEvent']],
|
||||
meta: {},
|
||||
})
|
||||
) {
|
||||
@@ -109,7 +104,7 @@ export const createOpenNationBettingHandler = (options: {
|
||||
});
|
||||
|
||||
const text = `새로운 ${shortName} 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.`;
|
||||
const now = new Date();
|
||||
const now = new Date(environment.turnTime.getTime());
|
||||
for (const general of generals.filter((entry) => entry.npcState <= 1)) {
|
||||
const nation = world.getNationById(general.nationId);
|
||||
world.queueMessage({
|
||||
|
||||
@@ -154,6 +154,25 @@ export class InMemoryReservedTurnStore {
|
||||
} satisfies InMemoryReservedTurnStateSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-path transaction savepoint. Queue mutations replace complete turn
|
||||
* arrays and journal sets instead of mutating captured entries in place,
|
||||
* so retaining those immutable references is sufficient for rollback.
|
||||
* Public inspection snapshots remain deep clones via captureState().
|
||||
*/
|
||||
captureTransactionState(): InMemoryReservedTurnStateSnapshot {
|
||||
return {
|
||||
generalTurns: Array.from(this.generalTurns.entries()),
|
||||
nationTurns: Array.from(this.nationTurns.entries()),
|
||||
dirtyGeneralIds: Array.from(this.dirtyGeneralIds),
|
||||
dirtyNationKeys: Array.from(this.dirtyNationKeys),
|
||||
pendingGeneralInitializationIds: Array.from(this.pendingGeneralInitializationIds),
|
||||
pendingNationInitializationKeys: Array.from(this.pendingNationInitializationKeys),
|
||||
leasedGeneralIds: Array.from(this.leasedGeneralIds),
|
||||
leasedNationKeys: Array.from(this.leasedNationKeys),
|
||||
};
|
||||
}
|
||||
|
||||
restoreState(snapshot: InMemoryReservedTurnStateSnapshot): void {
|
||||
const restored = structuredClone(snapshot);
|
||||
this.replaceMap(this.generalTurns, restored.generalTurns);
|
||||
@@ -232,11 +251,7 @@ export class InMemoryReservedTurnStore {
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where: {
|
||||
generalId,
|
||||
OR: [
|
||||
{ leaseOwner: this.leaseOwner },
|
||||
{ leaseOwner: null },
|
||||
{ leaseExpiresAt: { lte: now } },
|
||||
],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
||||
},
|
||||
data: {
|
||||
leaseOwner: this.leaseOwner,
|
||||
@@ -252,11 +267,7 @@ export class InMemoryReservedTurnStore {
|
||||
claimed = await revisionStore.updateMany({
|
||||
where: {
|
||||
generalId,
|
||||
OR: [
|
||||
{ leaseOwner: this.leaseOwner },
|
||||
{ leaseOwner: null },
|
||||
{ leaseExpiresAt: { lte: now } },
|
||||
],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
||||
},
|
||||
data: {
|
||||
leaseOwner: this.leaseOwner,
|
||||
@@ -282,11 +293,7 @@ export class InMemoryReservedTurnStore {
|
||||
where: {
|
||||
nationId,
|
||||
officerLevel,
|
||||
OR: [
|
||||
{ leaseOwner: this.leaseOwner },
|
||||
{ leaseOwner: null },
|
||||
{ leaseExpiresAt: { lte: now } },
|
||||
],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
||||
},
|
||||
data: {
|
||||
leaseOwner: this.leaseOwner,
|
||||
@@ -303,11 +310,7 @@ export class InMemoryReservedTurnStore {
|
||||
where: {
|
||||
nationId,
|
||||
officerLevel,
|
||||
OR: [
|
||||
{ leaseOwner: this.leaseOwner },
|
||||
{ leaseOwner: null },
|
||||
{ leaseExpiresAt: { lte: now } },
|
||||
],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
|
||||
},
|
||||
data: {
|
||||
leaseOwner: this.leaseOwner,
|
||||
@@ -525,10 +528,7 @@ export class InMemoryReservedTurnStore {
|
||||
}
|
||||
}
|
||||
|
||||
private async claimGeneralFlushLease(
|
||||
prisma: ReservedTurnDatabaseClient,
|
||||
generalId: number
|
||||
): Promise<boolean> {
|
||||
private async claimGeneralFlushLease(prisma: ReservedTurnDatabaseClient, generalId: number): Promise<boolean> {
|
||||
const revisionStore = prisma.generalTurnRevision;
|
||||
if (!revisionStore) {
|
||||
return false;
|
||||
@@ -538,11 +538,7 @@ export class InMemoryReservedTurnStore {
|
||||
? { generalId, leaseOwner: this.leaseOwner }
|
||||
: {
|
||||
generalId,
|
||||
OR: [
|
||||
{ leaseOwner: this.leaseOwner },
|
||||
{ leaseOwner: null },
|
||||
{ leaseExpiresAt: { lte: new Date() } },
|
||||
],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
|
||||
};
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where,
|
||||
@@ -613,11 +609,7 @@ export class InMemoryReservedTurnStore {
|
||||
: {
|
||||
nationId,
|
||||
officerLevel,
|
||||
OR: [
|
||||
{ leaseOwner: this.leaseOwner },
|
||||
{ leaseOwner: null },
|
||||
{ leaseExpiresAt: { lte: new Date() } },
|
||||
],
|
||||
OR: [{ leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }],
|
||||
};
|
||||
let claimed = await revisionStore.updateMany({
|
||||
where,
|
||||
|
||||
@@ -47,15 +47,18 @@ const syncAuctionTimers = async (
|
||||
): Promise<number> => {
|
||||
const auctions = await db.auction.findMany({
|
||||
where: { status: 'OPEN' },
|
||||
select: { id: true, closeAt: true },
|
||||
select: { id: true, closeAt: true, closeTick: true },
|
||||
});
|
||||
if (auctions.length > 0) {
|
||||
await redis.zAdd(
|
||||
`sammo:${profileName}:auction:timer`,
|
||||
auctions.map((auction) => ({
|
||||
score: auction.closeAt.getTime(),
|
||||
value: String(auction.id),
|
||||
}))
|
||||
auctions.map((auction) => {
|
||||
const score = auction.closeTick == null ? auction.closeAt.getTime() : Number(auction.closeTick);
|
||||
if (!Number.isSafeInteger(score)) {
|
||||
throw new Error(`Auction ${auction.id} has an unsafe logical deadline: ${auction.closeTick}`);
|
||||
}
|
||||
return { score, value: String(auction.id) };
|
||||
})
|
||||
);
|
||||
}
|
||||
return auctions.length;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
|
||||
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
|
||||
import { buildGameEventChannel, GameClock, type GameClockMode, type RealtimeEvent } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface TurnDaemonRuntimeOptions {
|
||||
gatewayDatabaseUrl?: string;
|
||||
defaultBudget?: TurnRunBudget;
|
||||
clock?: Clock;
|
||||
gameClockMode?: GameClockMode;
|
||||
controlQueue?: TurnDaemonControlQueue;
|
||||
schedule?: TurnSchedule;
|
||||
tickMinutes?: number;
|
||||
@@ -195,9 +196,31 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
databaseUrl: options.databaseUrl,
|
||||
mapOptions: options.mapOptions,
|
||||
});
|
||||
const clock = options.clock ?? new SystemClock();
|
||||
|
||||
const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes);
|
||||
const resolvedState = options.tickMinutes ? { ...state, tickSeconds: tickMinutes * 60 } : state;
|
||||
const nextTickSeconds = tickMinutes * 60;
|
||||
const tickSecondsChanged = options.tickMinutes !== undefined && nextTickSeconds !== state.tickSeconds;
|
||||
const clockBaseTime = tickSecondsChanged
|
||||
? GameClock.baseTimeForProjection(
|
||||
new GameClock({
|
||||
baseTime: state.clockBaseTime ?? state.lastTurnTime,
|
||||
tick: state.clockTick ?? state.lastTurnTick ?? 0,
|
||||
mode: state.clockMode ?? 'manual',
|
||||
wallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
|
||||
turnSeconds: state.tickSeconds,
|
||||
}).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0),
|
||||
state.clockTick ?? state.lastTurnTick ?? 0,
|
||||
nextTickSeconds
|
||||
)
|
||||
: state.clockBaseTime;
|
||||
const modeChanged = options.gameClockMode !== undefined && options.gameClockMode !== state.clockMode;
|
||||
const resolvedState = {
|
||||
...state,
|
||||
...(options.tickMinutes ? { tickSeconds: nextTickSeconds, clockBaseTime } : {}),
|
||||
...(options.gameClockMode ? { clockMode: options.gameClockMode } : {}),
|
||||
...(modeChanged ? { clockWallAnchor: new Date(clock.nowMs()) } : {}),
|
||||
};
|
||||
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
|
||||
const hasEventAction = (name: string): boolean =>
|
||||
snapshot.events.some(
|
||||
@@ -219,6 +242,8 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
? null
|
||||
: await createReservedTurnStore({
|
||||
databaseUrl: options.databaseUrl,
|
||||
leaseOwner: options.leaseOwnerId,
|
||||
leaseDurationMs: options.leaseDurationMs,
|
||||
});
|
||||
const commandProfile =
|
||||
options.commandProfile ??
|
||||
@@ -462,6 +487,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
getWorldConfig: () => snapshot.worldConfig ?? null,
|
||||
getNationPowerRollCount: () => monthlyNationPowerRollCount,
|
||||
getTournamentRollConsumed: () => monthlyTournamentRollConsumed,
|
||||
now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()),
|
||||
});
|
||||
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
|
||||
profileName: options.profileName ?? options.profile,
|
||||
@@ -475,7 +501,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
// Deterministic/manual runtimes must schedule the tournament from the
|
||||
// same clock that advances the game world. Production still falls
|
||||
// back to the system clock.
|
||||
now: () => new Date(options.clock?.nowMs() ?? Date.now()),
|
||||
now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()),
|
||||
});
|
||||
const yearbookHandler = createYearbookHandler({
|
||||
profileName: options.profileName ?? options.profile,
|
||||
@@ -537,7 +563,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
});
|
||||
if (reservedTurnStoreHandle) {
|
||||
stateManager.register('reservedTurns', {
|
||||
capture: () => reservedTurnStoreHandle.store.captureState(),
|
||||
capture: () => reservedTurnStoreHandle.store.captureTransactionState(),
|
||||
restore: (captured) => reservedTurnStoreHandle.store.restoreState(captured),
|
||||
inspect: () => reservedTurnStoreHandle.store.inspectState(),
|
||||
});
|
||||
@@ -604,7 +630,6 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
: undefined,
|
||||
});
|
||||
const controlQueue = options.controlQueue ?? new InMemoryControlQueue();
|
||||
const clock = options.clock ?? new SystemClock();
|
||||
|
||||
let hooks: TurnDaemonHooks | undefined;
|
||||
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
|
||||
@@ -785,7 +810,11 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
|
||||
commandHandler,
|
||||
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
|
||||
stateManager,
|
||||
// The exclusive fixture runner aborts the entire in-memory runtime
|
||||
// on failure and has no concurrent writer. Avoid cloning the whole
|
||||
// accumulated world before every due tick in that isolated mode;
|
||||
// production and gateway-managed runtimes keep rollback savepoints.
|
||||
stateManager: options.exclusiveFastForward ? undefined : stateManager,
|
||||
},
|
||||
{ profile: options.profile, defaultBudget }
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
WorldSnapshot,
|
||||
GeneralLastTurn,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { GameClockMode } from '@sammo-ts/common';
|
||||
|
||||
export interface TurnWorldState {
|
||||
id: number;
|
||||
@@ -17,6 +18,11 @@ export interface TurnWorldState {
|
||||
currentMonth: number;
|
||||
tickSeconds: number;
|
||||
lastTurnTime: Date;
|
||||
clockBaseTime?: Date;
|
||||
clockTick?: number;
|
||||
clockMode?: GameClockMode;
|
||||
clockWallAnchor?: Date;
|
||||
lastTurnTick?: number;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -29,7 +35,9 @@ export interface TurnGeneral extends General {
|
||||
picture?: string | null;
|
||||
imageServer?: number;
|
||||
turnTime: Date;
|
||||
turnTick?: number;
|
||||
recentWarTime?: Date | null;
|
||||
recentWarTick?: number | null;
|
||||
lastTurn?: GeneralLastTurn;
|
||||
penalty?: unknown;
|
||||
inheritancePoints?: Record<string, number>;
|
||||
|
||||
@@ -173,6 +173,26 @@ const resolveCommandAcceptedAt = async (
|
||||
return event.createdAt;
|
||||
};
|
||||
|
||||
const resolveOperationalAcceptedAt = async (
|
||||
db: DatabaseClient,
|
||||
command: Pick<TurnDaemonCommand, 'type' | 'requestId'>
|
||||
): Promise<Date> => {
|
||||
if (!command.requestId) {
|
||||
return new Date();
|
||||
}
|
||||
const event = await db.inputEvent.findUnique({
|
||||
where: { requestId: command.requestId },
|
||||
select: { createdAt: true, target: true, eventType: true },
|
||||
});
|
||||
if (!event) {
|
||||
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
|
||||
}
|
||||
if (event.target !== 'ENGINE' || event.eventType !== command.type) {
|
||||
throw new Error(`ENGINE input event type does not match ${command.type}.`);
|
||||
}
|
||||
return event.createdAt;
|
||||
};
|
||||
|
||||
const assertImmediateGeneralActionActor = async (
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'buildNationCandidate' | 'instantRetreat' }>,
|
||||
@@ -208,7 +228,8 @@ async function handleJoinCreateGeneral(
|
||||
if (!worldState) {
|
||||
throw new Error('Join world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
||||
try {
|
||||
return {
|
||||
type: 'joinCreateGeneral',
|
||||
@@ -272,7 +293,8 @@ async function handleNpcPossessGeneral(
|
||||
if (!worldState) {
|
||||
throw new Error('NPC possession world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
||||
try {
|
||||
return {
|
||||
type: 'npcPossessGeneral',
|
||||
@@ -313,7 +335,8 @@ async function handleSelectPoolCreate(
|
||||
if (!worldState) {
|
||||
throw new Error('Selection-pool world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolCreate',
|
||||
@@ -356,7 +379,8 @@ async function handleSelectPoolReselect(
|
||||
if (!worldState) {
|
||||
throw new Error('Selection-pool world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolReselect',
|
||||
@@ -832,7 +856,8 @@ async function handleShiftSchedule(
|
||||
};
|
||||
}
|
||||
|
||||
const shifted = ctx.world.shiftSchedule(command.deltaMinutes);
|
||||
const operationalAcceptedAt = await resolveOperationalAcceptedAt(ctx.commandDb, command);
|
||||
const shifted = ctx.world.shiftSchedule(command.deltaMinutes, operationalAcceptedAt);
|
||||
const shiftedAuctions = await ctx.commandDb.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
@@ -841,6 +866,46 @@ async function handleShiftSchedule(
|
||||
WHERE status = 'OPEN'
|
||||
`
|
||||
);
|
||||
await ctx.commandDb.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE select_pool
|
||||
SET reserved_until = reserved_until + (${command.deltaMinutes} * INTERVAL '1 minute')
|
||||
WHERE reserved_until IS NOT NULL
|
||||
`
|
||||
);
|
||||
await ctx.commandDb.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE select_npc_token
|
||||
SET valid_until = valid_until + (${command.deltaMinutes} * INTERVAL '1 minute'),
|
||||
pick_more_from = pick_more_from + (${command.deltaMinutes} * INTERVAL '1 minute')
|
||||
`
|
||||
);
|
||||
await ctx.commandDb.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE message
|
||||
SET time = CASE WHEN time_tick IS NULL THEN time ELSE time + (${command.deltaMinutes} * INTERVAL '1 minute') END,
|
||||
valid_until = CASE
|
||||
WHEN valid_until_tick IS NULL THEN valid_until
|
||||
ELSE valid_until + (${command.deltaMinutes} * INTERVAL '1 minute')
|
||||
END
|
||||
WHERE time_tick IS NOT NULL OR valid_until_tick IS NOT NULL
|
||||
`
|
||||
);
|
||||
await ctx.commandDb.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET start_at = CASE WHEN start_tick IS NULL THEN start_at ELSE start_at + (${command.deltaMinutes} * INTERVAL '1 minute') END,
|
||||
end_at = CASE
|
||||
WHEN end_tick IS NULL THEN end_at
|
||||
ELSE end_at + (${command.deltaMinutes} * INTERVAL '1 minute')
|
||||
END,
|
||||
closed_at = CASE
|
||||
WHEN closed_at IS NULL THEN NULL
|
||||
ELSE closed_at + (${command.deltaMinutes} * INTERVAL '1 minute')
|
||||
END
|
||||
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL OR closed_at IS NOT NULL
|
||||
`
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'shiftSchedule',
|
||||
@@ -1809,7 +1874,7 @@ async function handleKick(
|
||||
src: messageTarget,
|
||||
dest: messageTarget,
|
||||
text,
|
||||
time: new Date(),
|
||||
time: world.getGameNow(new Date()),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
});
|
||||
|
||||
@@ -24,9 +24,8 @@ import type {
|
||||
import { normalizeScenarioEffect } from '@sammo-ts/logic';
|
||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
||||
import { z } from 'zod';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { GameClock, asRecord, isRecord, type GameClockMode } from '@sammo-ts/common';
|
||||
|
||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
||||
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
|
||||
import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
|
||||
@@ -78,6 +77,21 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string): number | nu
|
||||
return null;
|
||||
};
|
||||
|
||||
const toSafeTick = (value: bigint, field: string): number => {
|
||||
const tick = Number(value);
|
||||
if (!Number.isSafeInteger(tick)) {
|
||||
throw new Error(`${field} is outside the JavaScript safe integer range: ${value}`);
|
||||
}
|
||||
return tick;
|
||||
};
|
||||
|
||||
const parseClockMode = (value: string): GameClockMode => {
|
||||
if (value === 'realtime' || value === 'manual') {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`world_state.clock_mode is invalid: ${value}`);
|
||||
};
|
||||
|
||||
const zScenarioStatBlock = z.object({
|
||||
total: z.number(),
|
||||
min: z.number(),
|
||||
@@ -130,38 +144,29 @@ const parseScenarioMeta = (meta: JsonRecord): ScenarioMeta | undefined => {
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
};
|
||||
|
||||
const parseLastTurnTime = (meta: JsonRecord): Date | null => {
|
||||
const parseLegacyLastTurnTime = (meta: JsonRecord): Date | null => {
|
||||
const raw = meta.lastTurnTime;
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(raw);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const resolveFallbackTurnTimeBase = (generals: TurnGeneral[], updatedAt: Date | null): Date => {
|
||||
let earliest: Date | null = null;
|
||||
for (const general of generals) {
|
||||
const turnTime = general.turnTime;
|
||||
if (!earliest || turnTime.getTime() < earliest.getTime()) {
|
||||
earliest = turnTime;
|
||||
}
|
||||
const resolveLegacyTurnTime = (
|
||||
generalRows: readonly TurnEngineGeneralRow[],
|
||||
meta: JsonRecord,
|
||||
updatedAt: Date | null | undefined
|
||||
): Date => {
|
||||
const stored = parseLegacyLastTurnTime(meta);
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
if (earliest) {
|
||||
return earliest;
|
||||
}
|
||||
if (updatedAt) {
|
||||
return updatedAt;
|
||||
}
|
||||
return new Date();
|
||||
};
|
||||
|
||||
const alignToPreviousTick = (base: Date, tickMinutes: number): Date => {
|
||||
const nextTick = getNextTickTime(base, tickMinutes);
|
||||
return new Date(nextTick.getTime() - tickMinutes * 60_000);
|
||||
const earliest = generalRows.reduce<Date | null>(
|
||||
(result, row) => (!result || row.turnTime.getTime() < result.getTime() ? row.turnTime : result),
|
||||
null
|
||||
);
|
||||
return earliest ?? updatedAt ?? new Date();
|
||||
};
|
||||
|
||||
const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
|
||||
@@ -180,6 +185,7 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
|
||||
|
||||
const mapGeneralRow = (
|
||||
row: TurnEngineGeneralRow,
|
||||
gameClock: GameClock,
|
||||
rankRows: readonly TurnEngineRankDataRow[],
|
||||
inheritanceRows: readonly TurnEngineInheritancePointRow[],
|
||||
accessRow?: TurnEngineGeneralAccessLogRow
|
||||
@@ -248,8 +254,24 @@ const mapGeneralRow = (
|
||||
lastTurn: normalizeGeneralLastTurn(row.lastTurn),
|
||||
penalty: row.penalty,
|
||||
// meta는 상단에서 보장 처리됨.
|
||||
turnTime: row.turnTime,
|
||||
recentWarTime: row.recentWarTime ?? null,
|
||||
turnTick:
|
||||
row.turnTick === null
|
||||
? gameClock.dateToTick(row.turnTime)
|
||||
: toSafeTick(row.turnTick, `general.turn_tick(${row.id})`),
|
||||
turnTime:
|
||||
row.turnTick === null
|
||||
? row.turnTime
|
||||
: gameClock.tickToDate(toSafeTick(row.turnTick, `general.turn_tick(${row.id})`)),
|
||||
recentWarTick:
|
||||
row.recentWarTick === null
|
||||
? row.recentWarTime
|
||||
? gameClock.dateToTick(row.recentWarTime)
|
||||
: null
|
||||
: toSafeTick(row.recentWarTick, `general.recent_war_tick(${row.id})`),
|
||||
recentWarTime:
|
||||
row.recentWarTick === null
|
||||
? (row.recentWarTime ?? null)
|
||||
: gameClock.tickToDate(toSafeTick(row.recentWarTick, `general.recent_war_tick(${row.id})`)),
|
||||
inheritancePoints,
|
||||
...(accessRow ? { refreshScoreTotal: accessRow.refreshScoreTotal } : {}),
|
||||
};
|
||||
@@ -376,6 +398,35 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
}),
|
||||
]);
|
||||
|
||||
const meta = asRecord(worldState.meta);
|
||||
const legacyLastTurnTime = resolveLegacyTurnTime(generalRows, meta, worldState.updatedAt);
|
||||
const hasPersistedClock =
|
||||
worldState.clockBaseTime !== null &&
|
||||
worldState.clockTick !== null &&
|
||||
worldState.clockWallAnchor !== null &&
|
||||
worldState.lastTurnTick !== null;
|
||||
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
|
||||
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
|
||||
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
|
||||
const bootstrapClock = new GameClock({
|
||||
baseTime: clockBaseTime,
|
||||
tick: 0,
|
||||
mode: clockMode,
|
||||
wallAnchor: clockWallAnchor,
|
||||
turnSeconds: worldState.tickSeconds,
|
||||
});
|
||||
const legacyLastTurnTick = bootstrapClock.dateToTick(legacyLastTurnTime);
|
||||
const gameClock = new GameClock({
|
||||
baseTime: clockBaseTime,
|
||||
tick:
|
||||
worldState.clockTick === null
|
||||
? legacyLastTurnTick
|
||||
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
|
||||
mode: clockMode,
|
||||
wallAnchor: clockWallAnchor,
|
||||
turnSeconds: worldState.tickSeconds,
|
||||
});
|
||||
|
||||
const ranksByGeneral = new Map<number, TurnEngineRankDataRow[]>();
|
||||
for (const row of rankRows) {
|
||||
const bucket = ranksByGeneral.get(row.generalId) ?? [];
|
||||
@@ -396,6 +447,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
.map((row) =>
|
||||
mapGeneralRow(
|
||||
row,
|
||||
gameClock,
|
||||
ranksByGeneral.get(row.id) ?? [],
|
||||
row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [],
|
||||
accessByGeneral.get(row.id)
|
||||
@@ -406,10 +458,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
const nations = nationRows.map(mapNationRow).sort((left, right) => left.id - right.id);
|
||||
const diplomacy = diplomacyRows
|
||||
.map(mapDiplomacyRow)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.fromNationId - right.fromNationId || left.toNationId - right.toNationId
|
||||
);
|
||||
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
|
||||
const troops = troopRows.map(mapTroopRow).sort((left, right) => left.id - right.id);
|
||||
|
||||
const worldConfig = asRecord(worldState.config);
|
||||
@@ -419,12 +468,13 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
const unitSetName = scenarioConfig.environment?.unitSet ?? 'che';
|
||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName, options.unitSetOptions);
|
||||
|
||||
const meta = asRecord(worldState.meta);
|
||||
const scenarioMeta = parseScenarioMeta(meta);
|
||||
|
||||
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
|
||||
const fallbackBase = resolveFallbackTurnTimeBase(generals, worldState.updatedAt ?? null);
|
||||
const lastTurnTime = parseLastTurnTime(meta) ?? alignToPreviousTick(fallbackBase, tickMinutes);
|
||||
const lastTurnTick =
|
||||
worldState.lastTurnTick === null
|
||||
? legacyLastTurnTick
|
||||
: toSafeTick(worldState.lastTurnTick, 'world_state.last_turn_tick');
|
||||
const lastTurnTime = gameClock.tickToDate(lastTurnTick);
|
||||
|
||||
const events = eventRows.filter((row) => row.targetCode !== 'initial').map(mapEventRow);
|
||||
const initialEvents = eventRows.filter((row) => row.targetCode === 'initial').map(mapEventRow);
|
||||
@@ -436,6 +486,11 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
currentMonth: worldState.currentMonth,
|
||||
tickSeconds: worldState.tickSeconds,
|
||||
lastTurnTime,
|
||||
clockBaseTime: gameClock.baseTime,
|
||||
clockTick: gameClock.tick,
|
||||
clockMode,
|
||||
clockWallAnchor: gameClock.wallAnchor,
|
||||
lastTurnTick,
|
||||
meta,
|
||||
},
|
||||
snapshot: {
|
||||
|
||||
@@ -257,7 +257,7 @@ describe('EngineStateManager', () => {
|
||||
store.replaceGeneralTurns(1, { action: '훈련', args: { amount: 10 } });
|
||||
const manager = new EngineStateManager();
|
||||
manager.register('reservedTurns', {
|
||||
capture: () => store.captureState(),
|
||||
capture: () => store.captureTransactionState(),
|
||||
restore: (snapshot) => store.restoreState(snapshot),
|
||||
});
|
||||
const before = store.captureState();
|
||||
|
||||
@@ -36,7 +36,7 @@ const buildGeneral = (id: number, turnTime: string): TurnGeneral =>
|
||||
npcState: 0,
|
||||
}) as TurnGeneral;
|
||||
|
||||
const buildWorld = (): InMemoryTurnWorld => {
|
||||
const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 190,
|
||||
@@ -50,6 +50,7 @@ const buildWorld = (): InMemoryTurnWorld => {
|
||||
tnmt_time: '2026-07-30 11:30:00',
|
||||
untouched: 'keep',
|
||||
},
|
||||
...stateOverride,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [buildGeneral(1, '2026-07-30T10:10:00.000Z'), buildGeneral(2, '2026-07-30T10:20:00.000Z')],
|
||||
@@ -134,6 +135,29 @@ describe('runtime clock shift', () => {
|
||||
tnmt_time: '2026-07-30 11:15:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('rebases after two years of downtime without catching up missed turns', () => {
|
||||
const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
|
||||
const resumedAt = new Date('2028-07-29T10:00:00.000Z');
|
||||
const deltaMinutes = 2 * 365 * 24 * 60;
|
||||
const world = buildWorld({
|
||||
clockBaseTime: wallAnchor,
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: wallAnchor,
|
||||
lastTurnTick: 0,
|
||||
});
|
||||
|
||||
const beforeTurnTick = world.getGeneralById(1)?.turnTick;
|
||||
world.shiftSchedule(deltaMinutes, resumedAt);
|
||||
|
||||
expect(world.getGameNow(resumedAt).toISOString()).toBe('2028-07-29T10:00:00.000Z');
|
||||
expect(world.getGameNow(new Date(resumedAt.getTime() + 10 * 60_000)).toISOString()).toBe(
|
||||
'2028-07-29T10:10:00.000Z'
|
||||
);
|
||||
expect(world.getGeneralById(1)?.turnTick).toBe(beforeTurnTick);
|
||||
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime clock shift projection', () => {
|
||||
@@ -186,7 +210,9 @@ describe('runtime clock shift projection', () => {
|
||||
),
|
||||
},
|
||||
auction: {
|
||||
findMany: vi.fn(async () => [{ id: 7, closeAt: new Date('2026-07-30T11:45:00.000Z') }]),
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 7, closeAt: new Date('2026-07-30T11:45:00.000Z'), closeTick: null },
|
||||
]),
|
||||
},
|
||||
} as unknown as GamePrismaClient;
|
||||
const values = new Map<string, string>([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { SystemClock } from '../src/lifecycle/clock.js';
|
||||
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||
import { getNextTickTime } from '../src/lifecycle/getNextTickTime.js';
|
||||
@@ -73,12 +74,14 @@ integration('runtime clock shift persistence', () => {
|
||||
await db.inputEvent.deleteMany({ where: { requestId } });
|
||||
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { requestId } });
|
||||
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
@@ -90,6 +93,11 @@ integration('runtime clock shift persistence', () => {
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: base,
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: base,
|
||||
lastTurnTick: 0,
|
||||
config: {},
|
||||
meta: {
|
||||
lastTurnTime: base.toISOString(),
|
||||
@@ -110,6 +118,7 @@ integration('runtime clock shift persistence', () => {
|
||||
cityId: general.cityId,
|
||||
troopId: general.troopId,
|
||||
turnTime: general.turnTime,
|
||||
turnTick: BigInt((general.id === generalIds[0] ? 1 : 2) * GAME_TICKS_PER_TURN),
|
||||
})),
|
||||
});
|
||||
const auctionRows = await Promise.all(
|
||||
@@ -132,6 +141,11 @@ integration('runtime clock shift persistence', () => {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: base,
|
||||
clockBaseTime: base,
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: base,
|
||||
lastTurnTick: 0,
|
||||
meta: row.meta as Record<string, unknown>,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
@@ -229,13 +243,16 @@ integration('runtime clock shift persistence', () => {
|
||||
generalId: 0,
|
||||
});
|
||||
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z');
|
||||
expect((await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).meta).toMatchObject({
|
||||
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
|
||||
expect(storedWorld.meta).toMatchObject({
|
||||
lastTurnTime: '2099-07-30T09:45:00.000Z',
|
||||
starttime: '2099-06-30 23:45:00',
|
||||
});
|
||||
expect((await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } })).turnTime.toISOString()).toBe(
|
||||
'2099-07-30T10:05:00.000Z'
|
||||
);
|
||||
expect(storedWorld.clockTick).toBe(0n);
|
||||
expect(storedWorld.lastTurnTick).toBe(0n);
|
||||
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } });
|
||||
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z');
|
||||
expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN));
|
||||
const storedAuctions = await db.auction.findMany({
|
||||
where: { id: { in: auctionRows.map((auction) => auction.id) } },
|
||||
});
|
||||
|
||||
@@ -14,6 +14,173 @@ import {
|
||||
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
||||
|
||||
describe('TurnDaemonLifecycle', () => {
|
||||
it('runs manual game time to each monthly snapshot without waiting for wall time', async () => {
|
||||
const wallNow = new Date('2026-01-01T00:00:00.000Z');
|
||||
const operationalClock = new ManualClock(wallNow.getTime());
|
||||
const queue = new InMemoryControlQueue();
|
||||
let lastTurnTime = new Date('2042-01-01T00:00:00.000Z');
|
||||
let gameNow = new Date(lastTurnTime);
|
||||
const targets: string[] = [];
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: operationalClock,
|
||||
controlQueue: queue,
|
||||
getNextTickTime: (value) => addMinutes(value, 60),
|
||||
stateStore: {
|
||||
loadLastTurnTime: async () => lastTurnTime,
|
||||
loadNextGeneralTurnTime: async () => addMinutes(lastTurnTime, 30),
|
||||
saveLastTurnTime: async (value) => {
|
||||
lastTurnTime = value;
|
||||
},
|
||||
loadCheckpoint: async () => undefined,
|
||||
saveCheckpoint: async () => {},
|
||||
loadGameClock: async () => ({ mode: 'manual', now: gameNow }),
|
||||
advanceGameClockTo: async (target) => {
|
||||
gameNow = target;
|
||||
},
|
||||
},
|
||||
processor: {
|
||||
run: async (target): Promise<TurnRunResult> => {
|
||||
targets.push(target.toISOString());
|
||||
if (targets.length === 3) {
|
||||
queue.enqueue({ type: 'shutdown', reason: 'verified' });
|
||||
}
|
||||
return {
|
||||
lastTurnTime: target.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 1,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
profile: 'manual-clock',
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
await lifecycle.start();
|
||||
|
||||
expect(targets).toEqual(['2042-01-01T01:00:00.000Z', '2042-01-01T02:00:00.000Z', '2042-01-01T03:00:00.000Z']);
|
||||
expect(operationalClock.nowMs()).toBe(wallNow.getTime());
|
||||
});
|
||||
|
||||
it('drains restart-overdue generals without advancing or catching up a month', async () => {
|
||||
const gameNow = new Date('2042-01-01T03:00:00.000Z');
|
||||
const queue = new InMemoryControlQueue();
|
||||
const observedTargets: Date[] = [];
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
|
||||
controlQueue: queue,
|
||||
getNextTickTime: (value) => addMinutes(value, 60),
|
||||
stateStore: {
|
||||
loadLastTurnTime: async () => gameNow,
|
||||
loadNextGeneralTurnTime: async () => addMinutes(gameNow, -30),
|
||||
saveLastTurnTime: async () => {},
|
||||
loadCheckpoint: async () => undefined,
|
||||
saveCheckpoint: async () => {},
|
||||
loadGameClock: async () => ({ mode: 'manual', now: gameNow }),
|
||||
advanceGameClockTo: async () => {},
|
||||
},
|
||||
processor: {
|
||||
run: async (target): Promise<TurnRunResult> => {
|
||||
observedTargets.push(target);
|
||||
queue.enqueue({ type: 'shutdown', reason: 'verified' });
|
||||
return {
|
||||
lastTurnTime: gameNow.toISOString(),
|
||||
processedGenerals: 1,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
profile: 'manual-overdue',
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
await lifecycle.start();
|
||||
|
||||
expect(observedTargets[0]?.toISOString()).toBe('2042-01-01T02:59:59.999Z');
|
||||
});
|
||||
|
||||
it('produces the same command, RNG, and resource state in realtime and manual modes', async () => {
|
||||
const start = new Date('2042-01-01T00:00:00.000Z');
|
||||
const runMode = async (mode: 'realtime' | 'manual') => {
|
||||
const operationalClock = new ManualClock(
|
||||
mode === 'realtime' ? start.getTime() + 3 * 60 * 60_000 : start.getTime()
|
||||
);
|
||||
const queue = new InMemoryControlQueue();
|
||||
let lastTurnTime = new Date(start);
|
||||
let gameNow = new Date(start);
|
||||
let rng = 17;
|
||||
let resource = 100;
|
||||
const commands: string[] = [];
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: operationalClock,
|
||||
controlQueue: queue,
|
||||
getNextTickTime: (value) => addMinutes(value, 60),
|
||||
stateStore: {
|
||||
loadLastTurnTime: async () => lastTurnTime,
|
||||
loadNextGeneralTurnTime: async () => null,
|
||||
saveLastTurnTime: async (value) => {
|
||||
lastTurnTime = value;
|
||||
},
|
||||
loadCheckpoint: async () => undefined,
|
||||
saveCheckpoint: async () => {},
|
||||
loadGameClock: async (wallNow) => ({
|
||||
mode,
|
||||
now:
|
||||
mode === 'manual'
|
||||
? gameNow
|
||||
: new Date(start.getTime() + ((wallNow ?? start).getTime() - start.getTime())),
|
||||
}),
|
||||
advanceGameClockTo: async (target) => {
|
||||
gameNow = target;
|
||||
},
|
||||
},
|
||||
processor: {
|
||||
run: async (target): Promise<TurnRunResult> => {
|
||||
while (lastTurnTime.getTime() < target.getTime()) {
|
||||
lastTurnTime = addMinutes(lastTurnTime, 60);
|
||||
rng = (rng * 48_271) % 2_147_483_647;
|
||||
const command = rng % 2 === 0 ? 'develop' : 'train';
|
||||
commands.push(command);
|
||||
resource += command === 'develop' ? 7 : -3;
|
||||
}
|
||||
if (commands.length >= 3) {
|
||||
queue.enqueue({ type: 'shutdown', reason: `${mode} verified` });
|
||||
}
|
||||
return {
|
||||
lastTurnTime: lastTurnTime.toISOString(),
|
||||
processedGenerals: commands.length,
|
||||
processedTurns: commands.length,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
profile: `${mode}-equivalence`,
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 10 },
|
||||
}
|
||||
);
|
||||
|
||||
await lifecycle.start();
|
||||
return { commands, rng, resource, lastTurnTime: lastTurnTime.toISOString() };
|
||||
};
|
||||
|
||||
expect(await runMode('manual')).toEqual(await runMode('realtime'));
|
||||
});
|
||||
|
||||
it('restores engine state when a scheduled calculation throws', async () => {
|
||||
const now = new Date('2026-01-01T00:10:00.000Z');
|
||||
const queue = new InMemoryControlQueue();
|
||||
|
||||
@@ -170,6 +170,13 @@ describe('InMemoryTurnProcessor ordering', () => {
|
||||
expect(world.getNextGeneralId()).toBe(4);
|
||||
expect(world.getNextGeneralId()).toBe(5);
|
||||
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });
|
||||
|
||||
const overdue = world.getGeneralById(1);
|
||||
expect(overdue).toBeDefined();
|
||||
overdue!.turnTime = addMinutes(baseTime, 5);
|
||||
const overdueResult = await processor.run(addMinutes(baseTime, 5), budget);
|
||||
expect(overdueResult.processedGenerals).toBe(1);
|
||||
expect(executed.at(-1)).toBe(1);
|
||||
});
|
||||
|
||||
it('stops catch-up immediately after a calendar handler finalizes unification', async () => {
|
||||
|
||||
@@ -55,6 +55,7 @@ export const runProfileSeedCli = async (env: NodeJS.ProcessEnv = process.env): P
|
||||
databaseUrl,
|
||||
scenarioId: request.scenarioId,
|
||||
tickSeconds: request.tickSeconds,
|
||||
gameClockMode: process.env.GAME_CLOCK_MODE === 'manual' ? 'manual' : 'realtime',
|
||||
now: new Date(request.now),
|
||||
installOptions: request.installOptions
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GameClock, asRecord, type GameClockMode } from '@sammo-ts/common';
|
||||
|
||||
export interface AdminSeedUser {
|
||||
id: string;
|
||||
@@ -12,6 +12,7 @@ export interface SeedProfileDatabaseOptions {
|
||||
databaseUrl: string;
|
||||
scenarioId: number;
|
||||
tickSeconds?: number;
|
||||
gameClockMode?: GameClockMode;
|
||||
now?: Date;
|
||||
installOptions?: ScenarioInstallOptions;
|
||||
scenarioOptions?: Parameters<typeof seedScenarioToDatabase>[0]['scenarioOptions'];
|
||||
@@ -105,6 +106,13 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse
|
||||
const meta = asRecord(worldState.meta);
|
||||
const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null;
|
||||
const turnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date();
|
||||
const gameClock = new GameClock({
|
||||
baseTime: worldState.clockBaseTime ?? turnTime,
|
||||
tick: Number(worldState.clockTick ?? 0n),
|
||||
mode: worldState.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||
wallAnchor: worldState.clockWallAnchor ?? turnTime,
|
||||
turnSeconds: worldState.tickSeconds,
|
||||
});
|
||||
|
||||
await prisma.general.create({
|
||||
data: {
|
||||
@@ -122,6 +130,7 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
turnTime,
|
||||
turnTick: BigInt(gameClock.dateToTick(turnTime)),
|
||||
meta: {
|
||||
createdBy: 'admin-seed',
|
||||
killturn: 24,
|
||||
@@ -137,6 +146,7 @@ export const seedProfileDatabase = async (options: SeedProfileDatabaseOptions) =
|
||||
scenarioId: options.scenarioId,
|
||||
databaseUrl: options.databaseUrl,
|
||||
tickSeconds: options.tickSeconds,
|
||||
gameClockMode: options.gameClockMode,
|
||||
now: options.now,
|
||||
installOptions: options.installOptions,
|
||||
scenarioOptions: options.scenarioOptions,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 게임 시계
|
||||
|
||||
게임 진행 시각은 `world_state.clock_tick`이 기준입니다. 벽시계는 daemon lease,
|
||||
요청 timeout, 처리 budget과 같은 운영 제어에만 사용합니다. 장수 턴, 메시지
|
||||
유효기간, 투표, 경매와 대회 마감은 game tick 또는 그 tick에서 투영한 시각을
|
||||
사용합니다.
|
||||
|
||||
한 턴은 항상 `36,000,000` tick입니다. `tick_seconds`가 바뀌면 현재 표시
|
||||
시각이 유지되도록 `clock_base_time`을 다시 계산하므로, 기존 장수 턴 순서와
|
||||
남은 턴 수가 보존됩니다. DateTime 필드는 이전 데이터와 화면을 위한 투영값이며
|
||||
tick 필드가 존재하면 tick이 우선합니다.
|
||||
|
||||
## 실행 모드
|
||||
|
||||
- `GAME_CLOCK_MODE=realtime`: `clock_wall_anchor` 이후의 실제 경과시간을
|
||||
game tick으로 환산합니다. 벽시계가 뒤로 보정되어도 game tick은 감소하지
|
||||
않습니다.
|
||||
- `GAME_CLOCK_MODE=manual`: 벽시계를 읽어 게임을 전진하지 않습니다. turn
|
||||
daemon은 다음 월 tick을 즉시 관찰 시각으로 삼고 그보다 이른 장수 snapshot을
|
||||
실행하므로 wall-clock mode와 명령/RNG 순서를 유지한 채 장기 시뮬레이션을
|
||||
최대 속도로 진행할 수 있습니다. 재시작 때 남은 overdue 장수는 현재 game
|
||||
time보다 이른 범위만 먼저 처리합니다.
|
||||
|
||||
프로필 설치 시 선택한 모드는 DB에 저장됩니다. daemon의 환경변수는 로드한
|
||||
모드를 명시적으로 덮어쓸 때만 사용해 주세요.
|
||||
|
||||
## 중단 후 재개
|
||||
|
||||
운영 중단 시간을 따라잡지 않으려면 Gateway의 일정 지연/가속 작업을 사용해
|
||||
표시 기준시각을 옮깁니다. 이 작업은 `clock_base_time`과 DateTime 투영값을
|
||||
같이 이동하고, game tick 및 장수 턴 tick은 바꾸지 않습니다. 동시에
|
||||
`clock_wall_anchor`를 작업 실행 시각으로 다시 고정하므로 장기간 중단 뒤에도
|
||||
누락된 기간의 턴을 몰아서 실행하지 않습니다.
|
||||
|
||||
DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 migration
|
||||
재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은
|
||||
무기한 호환값은 안전한 정수 범위를 넘을 수 있으므로 tick을 `NULL`로 두고
|
||||
DateTime fallback을 사용합니다.
|
||||
@@ -32,6 +32,8 @@ features:
|
||||
[시간과 턴](./user/time-and-turns.md)과
|
||||
[커맨드 목록](./user/command-catalog.generated.md)을 확인해 주세요. Profile과
|
||||
Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요.
|
||||
게임 진행 시각과 운영 벽시계의 경계는
|
||||
[게임 시계](./architecture/game-clock.md)에 설명합니다.
|
||||
|
||||
세부 문서는 다음 책임으로 나뉩니다.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './rng.js';
|
||||
export * from './time/Clock.js';
|
||||
export * from './time/GameClock.js';
|
||||
export * from './util/BytesLike.js';
|
||||
export * from './util/convertBytesLikeToArrayBuffer.js';
|
||||
export * from './util/convertBytesLikeToUint8Array.js';
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
export const GAME_TICKS_PER_TURN = 36_000_000;
|
||||
export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export type GameClockMode = 'realtime' | 'manual';
|
||||
|
||||
export interface GameClockState {
|
||||
baseTime: Date;
|
||||
tick: number;
|
||||
mode: GameClockMode;
|
||||
wallAnchor: Date;
|
||||
turnSeconds: number;
|
||||
}
|
||||
|
||||
const requireSafeTick = (tick: number): number => {
|
||||
if (!Number.isSafeInteger(tick)) {
|
||||
throw new Error(`Game tick must be a safe integer: ${tick}`);
|
||||
}
|
||||
return tick;
|
||||
};
|
||||
|
||||
const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => {
|
||||
const wholeSeconds = Math.trunc(tick / ticksPerSecond);
|
||||
const remainingTicks = tick - wholeSeconds * ticksPerSecond;
|
||||
const milliseconds = wholeSeconds * 1_000 + Math.round((remainingTicks * 1_000) / ticksPerSecond);
|
||||
if (!Number.isSafeInteger(milliseconds)) {
|
||||
throw new Error(`Game tick offset is outside the safe millisecond range: ${milliseconds}`);
|
||||
}
|
||||
return milliseconds;
|
||||
};
|
||||
|
||||
export class GameClock {
|
||||
readonly baseTime: Date;
|
||||
readonly tick: number;
|
||||
readonly mode: GameClockMode;
|
||||
readonly wallAnchor: Date;
|
||||
readonly turnSeconds: number;
|
||||
readonly ticksPerSecond: number;
|
||||
|
||||
constructor(state: GameClockState) {
|
||||
if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) {
|
||||
throw new Error('turnSeconds must be a positive integer.');
|
||||
}
|
||||
if (GAME_TICKS_PER_TURN % state.turnSeconds !== 0) {
|
||||
throw new Error(`turnSeconds ${state.turnSeconds} cannot be represented as integer game ticks.`);
|
||||
}
|
||||
if (state.mode !== 'realtime' && state.mode !== 'manual') {
|
||||
throw new Error(`Unknown game clock mode: ${String(state.mode)}`);
|
||||
}
|
||||
if (Number.isNaN(state.baseTime.getTime()) || Number.isNaN(state.wallAnchor.getTime())) {
|
||||
throw new Error('Game clock anchors must be valid dates.');
|
||||
}
|
||||
this.baseTime = new Date(state.baseTime.getTime());
|
||||
this.tick = requireSafeTick(state.tick);
|
||||
this.mode = state.mode;
|
||||
this.wallAnchor = new Date(state.wallAnchor.getTime());
|
||||
this.turnSeconds = state.turnSeconds;
|
||||
this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds;
|
||||
}
|
||||
|
||||
static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date {
|
||||
requireSafeTick(tick);
|
||||
if (!Number.isInteger(turnSeconds) || turnSeconds <= 0 || GAME_TICKS_PER_TURN % turnSeconds !== 0) {
|
||||
throw new Error(`turnSeconds ${turnSeconds} cannot be represented as integer game ticks.`);
|
||||
}
|
||||
const ticksPerSecond = GAME_TICKS_PER_TURN / turnSeconds;
|
||||
const offsetMs = tickOffsetMilliseconds(tick, ticksPerSecond);
|
||||
const baseMs = projectedTime.getTime() - offsetMs;
|
||||
if (!Number.isSafeInteger(baseMs)) {
|
||||
throw new Error(`Projected game clock base is outside the safe Date range: ${baseMs}`);
|
||||
}
|
||||
const baseTime = new Date(baseMs);
|
||||
if (Number.isNaN(baseTime.getTime())) {
|
||||
throw new Error(`Projected game clock base is invalid: ${baseMs}`);
|
||||
}
|
||||
return baseTime;
|
||||
}
|
||||
|
||||
nowTick(wallNow: Date): number {
|
||||
if (this.mode === 'manual') {
|
||||
return this.tick;
|
||||
}
|
||||
// A wall-clock correction must never rewind already-observed gameplay.
|
||||
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
|
||||
return elapsedTicks <= 0 ? this.tick : this.addTicks(this.tick, elapsedTicks);
|
||||
}
|
||||
|
||||
now(wallNow: Date): Date {
|
||||
return this.tickToDate(this.nowTick(wallNow));
|
||||
}
|
||||
|
||||
dateToTick(date: Date): number {
|
||||
return requireSafeTick(this.ticksBetween(this.baseTime, date));
|
||||
}
|
||||
|
||||
tickToDate(tick: number): Date {
|
||||
requireSafeTick(tick);
|
||||
const milliseconds = tickOffsetMilliseconds(tick, this.ticksPerSecond);
|
||||
const projected = this.baseTime.getTime() + milliseconds;
|
||||
if (!Number.isSafeInteger(projected)) {
|
||||
throw new Error(`Projected game time is outside the safe Date range: ${projected}`);
|
||||
}
|
||||
const result = new Date(projected);
|
||||
if (Number.isNaN(result.getTime())) {
|
||||
throw new Error(`Projected game time is invalid: ${projected}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
addTicks(tick: number, delta: number): number {
|
||||
requireSafeTick(delta);
|
||||
return requireSafeTick(tick + delta);
|
||||
}
|
||||
|
||||
private ticksBetween(from: Date, to: Date): number {
|
||||
const milliseconds = to.getTime() - from.getTime();
|
||||
if (!Number.isSafeInteger(milliseconds)) {
|
||||
throw new Error('Game clock date difference is outside the safe integer range.');
|
||||
}
|
||||
const wholeSeconds = Math.trunc(milliseconds / 1_000);
|
||||
const remainingMilliseconds = milliseconds - wholeSeconds * 1_000;
|
||||
return requireSafeTick(
|
||||
wholeSeconds * this.ticksPerSecond + Math.round((remainingMilliseconds * this.ticksPerSecond) / 1_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GAME_TICKS_PER_TURN, GameClock, MAX_SAFE_GAME_TICK } from '../src/time/GameClock.js';
|
||||
|
||||
describe('GameClock', () => {
|
||||
const baseTime = new Date('2042-01-01T00:00:00.000Z');
|
||||
|
||||
it('projects the fixed Ref turn tick and ignores wall time in manual mode', () => {
|
||||
const clock = new GameClock({
|
||||
baseTime,
|
||||
tick: GAME_TICKS_PER_TURN,
|
||||
mode: 'manual',
|
||||
wallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
||||
turnSeconds: 3_600,
|
||||
});
|
||||
|
||||
expect(clock.ticksPerSecond).toBe(10_000);
|
||||
expect(clock.nowTick(new Date('2126-01-01T00:00:00.000Z'))).toBe(GAME_TICKS_PER_TURN);
|
||||
expect(clock.now(new Date('2126-01-01T00:00:00.000Z')).toISOString()).toBe('2042-01-01T01:00:00.000Z');
|
||||
});
|
||||
|
||||
it('accepts a large forward wall jump without losing tick precision', () => {
|
||||
const wallAnchor = new Date('2026-01-01T00:00:00.000Z');
|
||||
const clock = new GameClock({
|
||||
baseTime,
|
||||
tick: 0,
|
||||
mode: 'realtime',
|
||||
wallAnchor,
|
||||
turnSeconds: 7_200,
|
||||
});
|
||||
const jumped = new Date('2126-01-01T00:00:00.000Z');
|
||||
const tick = clock.nowTick(jumped);
|
||||
|
||||
expect(Number.isSafeInteger(tick)).toBe(true);
|
||||
expect(clock.tickToDate(tick).getTime() - baseTime.getTime()).toBe(jumped.getTime() - wallAnchor.getTime());
|
||||
});
|
||||
|
||||
it('does not rewind game time after a backward wall-clock correction', () => {
|
||||
const clock = new GameClock({
|
||||
baseTime,
|
||||
tick: GAME_TICKS_PER_TURN * 10,
|
||||
mode: 'realtime',
|
||||
wallAnchor: new Date('2026-01-02T00:00:00.000Z'),
|
||||
turnSeconds: 3_600,
|
||||
});
|
||||
|
||||
expect(clock.nowTick(new Date('2025-01-01T00:00:00.000Z'))).toBe(GAME_TICKS_PER_TURN * 10);
|
||||
});
|
||||
|
||||
it('projects near the safe tick boundary without unsafe intermediate multiplication', () => {
|
||||
const clock = new GameClock({
|
||||
baseTime: new Date(0),
|
||||
tick: 0,
|
||||
mode: 'manual',
|
||||
wallAnchor: new Date(0),
|
||||
turnSeconds: 7_200,
|
||||
});
|
||||
const tick = MAX_SAFE_GAME_TICK - (MAX_SAFE_GAME_TICK % clock.ticksPerSecond);
|
||||
const projected = clock.tickToDate(tick);
|
||||
|
||||
expect(Number.isNaN(projected.getTime())).toBe(false);
|
||||
expect(clock.dateToTick(projected)).toBe(tick);
|
||||
});
|
||||
});
|
||||
@@ -94,14 +94,19 @@ model TurnDaemonLease {
|
||||
}
|
||||
|
||||
model WorldState {
|
||||
id Int @id @default(autoincrement())
|
||||
scenarioCode String @map("scenario_code")
|
||||
currentYear Int @map("current_year")
|
||||
currentMonth Int @map("current_month")
|
||||
tickSeconds Int @map("tick_seconds")
|
||||
config Json @default(dbgenerated("'{}'::jsonb"))
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id Int @id @default(autoincrement())
|
||||
scenarioCode String @map("scenario_code")
|
||||
currentYear Int @map("current_year")
|
||||
currentMonth Int @map("current_month")
|
||||
tickSeconds Int @map("tick_seconds")
|
||||
clockBaseTime DateTime? @map("clock_base_time")
|
||||
clockTick BigInt? @map("clock_tick")
|
||||
clockMode String @default("realtime") @map("clock_mode")
|
||||
clockWallAnchor DateTime? @map("clock_wall_anchor")
|
||||
lastTurnTick BigInt? @map("last_turn_tick")
|
||||
config Json @default(dbgenerated("'{}'::jsonb"))
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
trafficPeriods TrafficPeriod[]
|
||||
|
||||
@@ -183,7 +188,9 @@ model General {
|
||||
horseCode String @default("None") @map("horse_code")
|
||||
itemCode String @default("None") @map("item_code")
|
||||
turnTime DateTime @map("turn_time")
|
||||
turnTick BigInt? @map("turn_tick")
|
||||
recentWarTime DateTime? @map("recent_war_time")
|
||||
recentWarTick BigInt? @map("recent_war_tick")
|
||||
age Int @default(20)
|
||||
startAge Int @default(20) @map("start_age")
|
||||
personalCode String @default("None") @map("personal_code")
|
||||
@@ -199,12 +206,13 @@ model General {
|
||||
}
|
||||
|
||||
model SelectPoolEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
uniqueName String @unique @map("unique_name") @db.VarChar(20)
|
||||
ownerUserId String? @map("owner_user_id")
|
||||
generalId Int? @unique @map("general_id")
|
||||
reservedUntil DateTime? @map("reserved_until")
|
||||
info Json
|
||||
id Int @id @default(autoincrement())
|
||||
uniqueName String @unique @map("unique_name") @db.VarChar(20)
|
||||
ownerUserId String? @map("owner_user_id")
|
||||
generalId Int? @unique @map("general_id")
|
||||
reservedUntil DateTime? @map("reserved_until")
|
||||
reservedUntilTick BigInt? @map("reserved_until_tick")
|
||||
info Json
|
||||
|
||||
@@index([ownerUserId])
|
||||
@@index([reservedUntil, generalId])
|
||||
@@ -212,13 +220,15 @@ model SelectPoolEntry {
|
||||
}
|
||||
|
||||
model NpcSelectionToken {
|
||||
ownerUserId String @id @map("owner_user_id")
|
||||
validUntil DateTime @map("valid_until")
|
||||
pickMoreFrom DateTime @map("pick_more_from")
|
||||
pickResult Json @map("pick_result")
|
||||
nonce Int
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
ownerUserId String @id @map("owner_user_id")
|
||||
validUntil DateTime @map("valid_until")
|
||||
validUntilTick BigInt? @map("valid_until_tick")
|
||||
pickMoreFrom DateTime @map("pick_more_from")
|
||||
pickMoreFromTick BigInt? @map("pick_more_from_tick")
|
||||
pickResult Json @map("pick_result")
|
||||
nonce Int
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([validUntil])
|
||||
@@map("select_npc_token")
|
||||
@@ -280,14 +290,16 @@ model MessageReadState {
|
||||
}
|
||||
|
||||
model Message {
|
||||
id Int @id @default(autoincrement())
|
||||
mailbox Int
|
||||
type String
|
||||
src Int
|
||||
dest Int
|
||||
time DateTime
|
||||
validUntil DateTime @map("valid_until")
|
||||
message Json
|
||||
id Int @id @default(autoincrement())
|
||||
mailbox Int
|
||||
type String
|
||||
src Int
|
||||
dest Int
|
||||
time DateTime
|
||||
timeTick BigInt? @map("time_tick")
|
||||
validUntil DateTime @map("valid_until")
|
||||
validUntilTick BigInt? @map("valid_until_tick")
|
||||
message Json
|
||||
|
||||
@@map("message")
|
||||
}
|
||||
@@ -674,6 +686,8 @@ model Auction {
|
||||
detail Json @default(dbgenerated("'{}'::jsonb"))
|
||||
status AuctionStatus @default(OPEN)
|
||||
closeAt DateTime @map("close_at")
|
||||
openTick BigInt? @map("open_tick")
|
||||
closeTick BigInt? @map("close_tick")
|
||||
latestEventId String @default("") @map("latest_event_id")
|
||||
latestEventAt DateTime @default(now()) @map("latest_event_at")
|
||||
finalizingAt DateTime? @map("finalizing_at")
|
||||
@@ -770,7 +784,9 @@ model VotePoll {
|
||||
openerGeneralId Int @map("opener_general_id")
|
||||
openerName String @map("opener_name")
|
||||
startAt DateTime @default(now()) @map("start_at")
|
||||
startTick BigInt? @map("start_tick")
|
||||
endAt DateTime? @map("end_at")
|
||||
endTick BigInt? @map("end_tick")
|
||||
closedAt DateTime? @map("closed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
ALTER TABLE world_state
|
||||
ADD COLUMN clock_base_time TIMESTAMP(3),
|
||||
ADD COLUMN clock_tick BIGINT,
|
||||
ADD COLUMN clock_mode TEXT NOT NULL DEFAULT 'realtime',
|
||||
ADD COLUMN clock_wall_anchor TIMESTAMP(3),
|
||||
ADD COLUMN last_turn_tick BIGINT;
|
||||
|
||||
UPDATE world_state
|
||||
SET clock_base_time = COALESCE(NULLIF(meta->>'lastTurnTime', '')::timestamp, updated_at),
|
||||
clock_wall_anchor = CURRENT_TIMESTAMP,
|
||||
clock_tick = ROUND(
|
||||
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - COALESCE(NULLIF(meta->>'lastTurnTime', '')::timestamp, updated_at)))
|
||||
* (36000000::numeric / tick_seconds)
|
||||
)::bigint,
|
||||
last_turn_tick = 0;
|
||||
|
||||
ALTER TABLE general
|
||||
ADD COLUMN turn_tick BIGINT,
|
||||
ADD COLUMN recent_war_tick BIGINT;
|
||||
|
||||
UPDATE general
|
||||
SET turn_tick = ROUND(
|
||||
EXTRACT(EPOCH FROM (general.turn_time - world_state.clock_base_time))
|
||||
* (36000000::numeric / world_state.tick_seconds)
|
||||
)::bigint,
|
||||
recent_war_tick = CASE WHEN general.recent_war_time IS NULL THEN NULL ELSE ROUND(
|
||||
EXTRACT(EPOCH FROM (general.recent_war_time - world_state.clock_base_time))
|
||||
* (36000000::numeric / world_state.tick_seconds)
|
||||
)::bigint END
|
||||
FROM world_state;
|
||||
|
||||
ALTER TABLE select_pool ADD COLUMN reserved_until_tick BIGINT;
|
||||
ALTER TABLE select_npc_token ADD COLUMN valid_until_tick BIGINT, ADD COLUMN pick_more_from_tick BIGINT;
|
||||
ALTER TABLE message ADD COLUMN time_tick BIGINT, ADD COLUMN valid_until_tick BIGINT;
|
||||
ALTER TABLE auction ADD COLUMN open_tick BIGINT, ADD COLUMN close_tick BIGINT;
|
||||
ALTER TABLE vote_poll ADD COLUMN start_tick BIGINT, ADD COLUMN end_tick BIGINT;
|
||||
|
||||
UPDATE select_pool SET reserved_until_tick = CASE WHEN reserved_until IS NULL THEN NULL ELSE ROUND(EXTRACT(EPOCH FROM (reserved_until - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint END FROM world_state;
|
||||
UPDATE select_npc_token SET valid_until_tick = ROUND(EXTRACT(EPOCH FROM (valid_until - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint, pick_more_from_tick = ROUND(EXTRACT(EPOCH FROM (pick_more_from - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint FROM world_state;
|
||||
UPDATE message
|
||||
SET time_tick = ROUND(
|
||||
EXTRACT(EPOCH FROM (time - world_state.clock_base_time))
|
||||
* (36000000::numeric / world_state.tick_seconds)
|
||||
)::bigint,
|
||||
valid_until_tick = CASE
|
||||
-- Legacy permanent messages use the year-9999 sentinel. Keep their
|
||||
-- DateTime fallback instead of persisting a tick JavaScript cannot
|
||||
-- represent safely.
|
||||
WHEN valid_until >= TIMESTAMP '9999-01-01 00:00:00' THEN NULL
|
||||
ELSE ROUND(
|
||||
EXTRACT(EPOCH FROM (valid_until - world_state.clock_base_time))
|
||||
* (36000000::numeric / world_state.tick_seconds)
|
||||
)::bigint
|
||||
END
|
||||
FROM world_state;
|
||||
UPDATE auction SET open_tick = ROUND(EXTRACT(EPOCH FROM (created_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint, close_tick = ROUND(EXTRACT(EPOCH FROM (close_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint FROM world_state;
|
||||
UPDATE vote_poll SET start_tick = ROUND(EXTRACT(EPOCH FROM (start_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint, end_tick = CASE WHEN end_at IS NULL THEN NULL ELSE ROUND(EXTRACT(EPOCH FROM (end_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint END FROM world_state;
|
||||
|
||||
CREATE INDEX general_turn_tick_id_idx ON general(turn_tick, id);
|
||||
CREATE INDEX auction_status_close_tick_idx ON auction(status, close_tick);
|
||||
@@ -9,6 +9,11 @@ export interface TurnEngineWorldStateRow {
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
tickSeconds: number;
|
||||
clockBaseTime: Date | null;
|
||||
clockTick: bigint | null;
|
||||
clockMode: string;
|
||||
clockWallAnchor: Date | null;
|
||||
lastTurnTick: bigint | null;
|
||||
config: JsonValue;
|
||||
meta: JsonValue;
|
||||
updatedAt?: Date | null;
|
||||
@@ -53,7 +58,9 @@ export interface TurnEngineGeneralRow {
|
||||
meta: JsonValue;
|
||||
penalty: JsonValue;
|
||||
turnTime: Date;
|
||||
turnTick: bigint | null;
|
||||
recentWarTime: Date | null;
|
||||
recentWarTick: bigint | null;
|
||||
}
|
||||
|
||||
export interface TurnEngineRankDataRow {
|
||||
@@ -157,6 +164,11 @@ export interface TurnEngineWorldStateUpdateInput {
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
tickSeconds: number;
|
||||
clockBaseTime: Date;
|
||||
clockTick: bigint;
|
||||
clockMode: string;
|
||||
clockWallAnchor: Date;
|
||||
lastTurnTick: bigint;
|
||||
meta: InputJsonValue;
|
||||
}
|
||||
|
||||
@@ -165,6 +177,11 @@ export interface TurnEngineWorldStateCreateInput {
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
tickSeconds: number;
|
||||
clockBaseTime: Date;
|
||||
clockTick: bigint;
|
||||
clockMode: string;
|
||||
clockWallAnchor: Date;
|
||||
lastTurnTick: bigint;
|
||||
config: InputJsonValue;
|
||||
meta: InputJsonValue;
|
||||
}
|
||||
@@ -207,7 +224,9 @@ export interface TurnEngineGeneralUpdateInput {
|
||||
penalty: InputJsonValue;
|
||||
meta: InputJsonValue;
|
||||
turnTime: Date;
|
||||
turnTick: bigint;
|
||||
recentWarTime: Date | null;
|
||||
recentWarTick: bigint | null;
|
||||
}
|
||||
|
||||
export interface TurnEngineGeneralCreateManyInput {
|
||||
@@ -241,7 +260,9 @@ export interface TurnEngineGeneralCreateManyInput {
|
||||
special2Code: string;
|
||||
meta: InputJsonValue;
|
||||
turnTime: Date;
|
||||
turnTick: bigint;
|
||||
recentWarTime?: Date | null;
|
||||
recentWarTick?: bigint | null;
|
||||
affinity?: number | null;
|
||||
bornYear?: number;
|
||||
deadYear?: number;
|
||||
|
||||
Reference in New Issue
Block a user