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