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);
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';
// A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands.
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
const openingFloor = cutTurn(requestedOpening, turnTermMinutes);
const initialClockWallAnchor =
gameClockMode === 'manual'
? requestedOpening
: new Date(openingFloor.getTime() + (openingFloor < requestedOpening ? tickSeconds * 1_000 : 0));
// Opening is an exact wall instant, independent of the calendar's 12-turn
// grouping. Only the initial year/month uses the legacy calendar alignment.
const initialClockWallAnchor = requestedOpening;
const startState = resolveStartState(
scenario.startYear ?? null,
gameClockMode === 'manual' ? now : requestedOpening,
turnTermMinutes,
sync
);
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
const initialClock = new GameClock({
baseTime: startState.startTime,
baseTime: gameClockMode === 'manual' ? startState.startTime : initialClockWallAnchor,
tick: 0,
mode: gameClockMode,
wallAnchor: initialClockWallAnchor,
@@ -324,9 +327,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// monthly pre-handler recalculates the same value at each boundary.
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
starttime: formatDateTime(startState.startTime),
turntime: formatDateTime(now),
turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
opentime: formatDateTime(initialClockWallAnchor),
lastTurnTime: formatDateTime(now),
lastTurnTime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
};
const firstGameIdx =
@@ -1,3 +1,4 @@
import { GameClock } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { describe, expect, test } from 'vitest';
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
@@ -94,6 +95,99 @@ const canRun = await canConnectToDatabase(databaseUrl);
const describeDb = describe.runIf(canRun);
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 () => {
const readPersistedItemContract = async (targetScenarioId: number) => {
const { applied } = await seedScenarioToDatabase({
@@ -71,7 +71,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({
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',
meta: {
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;
RECONCILING remains fenced until the Redis outbox is applied.
Planned realtime opening rounds upward to a turn boundary. The seed CLI passes
actual wall time separately from the requested game-calendar baseline, and
Gateway publishes the stored opening anchor for both display and scheduling. Unification wait
Planned realtime opening preserves the exact requested wall instant, including
minutes, seconds, and milliseconds. The seed CLI passes actual wall time separately;
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
next normal boundary without replaying the intentional waiting period. An old
pending unification ledger is upgraded on resume; applied history stays intact.