feat: add logical game clock

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