fix: 신규 오픈 시각과 게임 시계 원점을 정확히 보존

This commit is contained in:
2026-09-07 01:48:23 +00:00
parent 69f461612d
commit b2be86a9a2
4 changed files with 117 additions and 13 deletions
+12 -9
View File
@@ -242,20 +242,23 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS); : (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60)); const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
const sync = install?.sync ?? false; const sync = install?.sync ?? false;
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
const gameClockMode = options.gameClockMode ?? 'realtime'; const gameClockMode = options.gameClockMode ?? 'realtime';
// A realtime season prepared before its formal opening must not consume // A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands. // wall time while users are only allowed to edit reserved commands.
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now); const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow; const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
const openingFloor = cutTurn(requestedOpening, turnTermMinutes); // Opening is an exact wall instant, independent of the calendar's 12-turn
const initialClockWallAnchor = // grouping. Only the initial year/month uses the legacy calendar alignment.
gameClockMode === 'manual' const initialClockWallAnchor = requestedOpening;
? requestedOpening const startState = resolveStartState(
: new Date(openingFloor.getTime() + (openingFloor < requestedOpening ? tickSeconds * 1_000 : 0)); scenario.startYear ?? null,
gameClockMode === 'manual' ? now : requestedOpening,
turnTermMinutes,
sync
);
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor); const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
const initialClock = new GameClock({ const initialClock = new GameClock({
baseTime: startState.startTime, baseTime: gameClockMode === 'manual' ? startState.startTime : initialClockWallAnchor,
tick: 0, tick: 0,
mode: gameClockMode, mode: gameClockMode,
wallAnchor: initialClockWallAnchor, wallAnchor: initialClockWallAnchor,
@@ -324,9 +327,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// monthly pre-handler recalculates the same value at each boundary. // monthly pre-handler recalculates the same value at each boundary.
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2, develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
starttime: formatDateTime(startState.startTime), starttime: formatDateTime(startState.startTime),
turntime: formatDateTime(now), turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
opentime: formatDateTime(initialClockWallAnchor), opentime: formatDateTime(initialClockWallAnchor),
lastTurnTime: formatDateTime(now), lastTurnTime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
}; };
const firstGameIdx = const firstGameIdx =
@@ -1,3 +1,4 @@
import { GameClock } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra'; import { createGamePostgresConnector } from '@sammo-ts/infra';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js'; import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
@@ -94,6 +95,99 @@ const canRun = await canConnectToDatabase(databaseUrl);
const describeDb = describe.runIf(canRun); const describeDb = describe.runIf(canRun);
describeDb('scenario database seed', () => { describeDb('scenario database seed', () => {
test.each([
{ sync: true, turnMinutes: 60, hour: 10, month: 10, yearOffset: -1 },
{ sync: true, turnMinutes: 60, hour: 1, month: 1, yearOffset: 0 },
{ sync: true, turnMinutes: 60, hour: 13, month: 1, yearOffset: 0 },
{ sync: false, turnMinutes: 60, hour: 10, month: 1, yearOffset: 0 },
{ sync: true, turnMinutes: 5, hour: 10, month: 7, yearOffset: -1 },
])(
'preserves exact opening and its calendar: $sync / $hour / $turnMinutes',
async ({ sync, hour, month, yearOffset, turnMinutes }) => {
const openAt = new Date(2030, 0, 1, hour, 30, 15, 123);
const preopenAt = new Date(openAt.getTime() - 90 * 60_000);
const scenario = await loadScenarioDefinitionById(scenarioId);
await seedScenarioToDatabase({
scenarioId,
databaseUrl,
resetTables: true,
now: preopenAt,
wallNow: preopenAt,
installOptions: { sync, turnTermMinutes: turnMinutes, preopenAt, openAt },
});
const connector = createGamePostgresConnector({ url: databaseUrl });
try {
await connector.connect();
const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({
currentYear: (scenario.startYear ?? 0) + yearOffset,
currentMonth: month,
clockBaseTime: openAt,
clockWallAnchor: openAt,
clockTick: 0n,
lastTurnTick: 0n,
clockPhase: 'PREOPEN',
});
const clock = new GameClock({
baseTime: world.clockBaseTime!,
wallAnchor: world.clockWallAnchor!,
tick: Number(world.clockTick),
mode: 'realtime',
phase: 'PREOPEN',
turnSeconds: world.tickSeconds,
});
expect(clock.nowTick(new Date(openAt.getTime() - 1))).toBeLessThan(0);
expect(clock.nowTick(openAt)).toBe(0);
expect(clock.tickToDate(clock.nowTick(preopenAt))).toEqual(preopenAt);
const afterOpening = new Date(openAt.getTime() + turnMinutes * 60_000);
expect(clock.tickToDate(clock.nowTick(afterOpening))).toEqual(afterOpening);
expect(clock.nowTick(afterOpening)).toBe(36_000_000);
const generals = await connector.prisma.general.findMany({
select: { turnTick: true, turnTime: true },
});
expect(generals.length).toBeGreaterThan(0);
for (const general of generals) {
expect(general.turnTick).toBeGreaterThanOrEqual(0n);
expect(general.turnTime.getTime()).toBeGreaterThanOrEqual(openAt.getTime());
}
} finally {
await connector.disconnect();
}
}
);
test.each([false, true])(
'starts immediately without rounding when the opening is absent or late: %s',
async (late) => {
const wallNow = new Date(2030, 0, 1, 10, 30, 15, 123);
await seedScenarioToDatabase({
scenarioId,
databaseUrl,
resetTables: true,
now: new Date(2030, 0, 1, 1, 0),
wallNow,
installOptions: {
sync: true,
turnTermMinutes: 60,
openAt: late ? new Date(2030, 0, 1, 9, 0) : null,
},
});
const connector = createGamePostgresConnector({ url: databaseUrl });
try {
await connector.connect();
await expect(connector.prisma.worldState.findFirstOrThrow()).resolves.toMatchObject({
clockBaseTime: wallNow,
clockWallAnchor: wallNow,
clockPhase: 'RUNNING',
clockTick: 0n,
currentMonth: 10,
});
} finally {
await connector.disconnect();
}
}
);
test('persists each blank-land scenario item contract without leaking the shared addon', async () => { test('persists each blank-land scenario item contract without leaking the shared addon', async () => {
const readPersistedItemContract = async (targetScenarioId: number) => { const readPersistedItemContract = async (targetScenarioId: number) => {
const { applied } = await seedScenarioToDatabase({ const { applied } = await seedScenarioToDatabase({
@@ -71,7 +71,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
const world = await connector.prisma.worldState.findFirstOrThrow(); const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({ expect(world).toMatchObject({
scenarioCode: '1010', scenarioCode: '1010',
clockWallAnchor: new Date('2036-03-03T02:11:00.000Z'), clockWallAnchor: new Date('2036-03-03T02:10:30.000Z'),
clockBaseTime: new Date('2036-03-03T02:10:30.000Z'),
clockPhase: 'PREOPEN', clockPhase: 'PREOPEN',
meta: { meta: {
firstGameIdx: 0, firstGameIdx: 0,
@@ -72,9 +72,15 @@ setup. A paused profile is durably suspended during upgrade and stays suspended
until explicitly resumed. API/worker clock reads require a live ready lease at the read revision; until explicitly resumed. API/worker clock reads require a live ready lease at the read revision;
RECONCILING remains fenced until the Redis outbox is applied. RECONCILING remains fenced until the Redis outbox is applied.
Planned realtime opening rounds upward to a turn boundary. The seed CLI passes Planned realtime opening preserves the exact requested wall instant, including
actual wall time separately from the requested game-calendar baseline, and minutes, seconds, and milliseconds. The seed CLI passes actual wall time separately;
Gateway publishes the stored opening anchor for both display and scheduling. Unification wait an absent or already elapsed opening starts at the actual seed wall time. Tick zero's
game-date projection and wall anchor both use that effective opening instant.
The legacy 12-turn calendar grouping determines only the initial year/month and
calendar metadata; it must not round opening or offset the displayed clock.
PREOPEN admission keeps its separately requested instant. Gateway publishes the
stored opening anchor for both display and scheduling. Existing seasons are not
rebased by this seed-only change. Unification wait
uses `TURN_BOUNDARY`, cuts at the completed monthly cursor, and resumes at the uses `TURN_BOUNDARY`, cuts at the completed monthly cursor, and resumes at the
next normal boundary without replaying the intentional waiting period. An old next normal boundary without replaying the intentional waiting period. An old
pending unification ledger is upgraded on resume; applied history stays intact. pending unification ledger is upgraded on resume; applied history stays intact.