fix: 신규 오픈 시각과 게임 시계 원점을 정확히 보존
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user