diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index ee2914ab..5df77261 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -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 = diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index ffa7d19c..86932a8a 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -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({ diff --git a/app/gateway-api/test/profileSeedCli.integration.test.ts b/app/gateway-api/test/profileSeedCli.integration.test.ts index a725eca6..b53a6ec7 100644 --- a/app/gateway-api/test/profileSeedCli.integration.test.ts +++ b/app/gateway-api/test/profileSeedCli.integration.test.ts @@ -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, diff --git a/docs/architecture/game-clock-reconciliation.md b/docs/architecture/game-clock-reconciliation.md index 8520f18f..f16b84e9 100644 --- a/docs/architecture/game-clock-reconciliation.md +++ b/docs/architecture/game-clock-reconciliation.md @@ -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.