diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 1f201f11..bb605ce9 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -656,6 +656,16 @@ export class InMemoryTurnWorld { return this.getGameClock().now(wallNow); } + getRunnableGameNow(wallNow: Date): Date { + const clock = this.getGameClock(); + // PREOPEN still needs negative game ticks for cooldowns, but executable + // turn schedules must not precede the wall-clock opening boundary. + if (clock.mode === 'realtime' && wallNow.getTime() < clock.wallAnchor.getTime()) { + return clock.now(clock.wallAnchor); + } + return clock.now(wallNow); + } + dateToGameTick(date: Date): number { return this.getGameClock().dateToTick(date); } diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 16fa7d6e..f01fa873 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -383,7 +383,9 @@ export const resolveJoinTurnTime = ( } let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000); if (turnTime.getTime() <= acceptedAt.getTime()) { - turnTime = new Date(turnTime.getTime() + tickSeconds * 1000); + const termMs = tickSeconds * 1000; + const missedTerms = Math.floor((acceptedAt.getTime() - turnTime.getTime()) / termMs) + 1; + turnTime = new Date(turnTime.getTime() + missedTerms * termMs); } return turnTime; }; @@ -523,9 +525,11 @@ export const createGeneralFromJoin = async (options: { worldState: WorldStateRow; input: JoinCreateGeneralInput; acceptedAt: Date; + turnScheduleAt?: Date; operationalAcceptedAt: Date; }): Promise<{ ok: true; generalId: number }> => { const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options; + const turnScheduleAt = options.turnScheduleAt ?? acceptedAt; await lockJoinMutation(db, input.userId); await assertGeneralIdSnapshotMatches(db, world); @@ -707,7 +711,7 @@ export const createGeneralFromJoin = async (options: { const turnTime = resolveJoinTurnTime( rng, worldState, - acceptedAt, + turnScheduleAt, world.getState().lastTurnTime, input.inheritTurntimeZone ); diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index 10f832d1..7ad581db 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -651,11 +651,18 @@ const resolveTurnTimeBase = (worldState: WorldStateRow, now: Date): Date => { return now; }; -const buildInitialTurnTime = (rng: RandUtil, worldState: WorldStateRow, now: Date): Date => { +export const buildInitialTurnTime = ( + rng: Pick, + worldState: WorldStateRow, + now: Date, + notBefore: Date = now +): Date => { const termSeconds = resolveTurnTermMinutes(worldState) * 60; const seconds = rng.nextRangeInt(0, termSeconds - 1); const microseconds = rng.nextRangeInt(0, 999_999); - return new Date(resolveTurnTimeBase(worldState, now).getTime() + seconds * 1000 + microseconds / 1000); + const base = resolveTurnTimeBase(worldState, now); + const safeBase = base.getTime() < notBefore.getTime() ? notBefore : base; + return new Date(safeBase.getTime() + seconds * 1000 + microseconds / 1000); }; const appendSelectionLogs = async (options: { @@ -702,6 +709,7 @@ export const createGeneralFromSelectionPool = async (options: { uniqueName: string; personality: string; now?: Date; + turnScheduleAt?: Date; operationalAcceptedAt: Date; seedOwnerIdentity?: string | number; ownerPicture?: string; @@ -761,7 +769,7 @@ export const createGeneralFromSelectionPool = async (options: { ); } const city = rng.choice(cities); - const turnTime = buildInitialTurnTime(rng, worldState, now); + const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now); const age = 20; const specialityAges = resolveSpecialityAges(worldState, age); const nextChangeAt = new Date( diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 16ce9125..829cdef6 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -340,6 +340,7 @@ async function handleJoinCreateGeneral( } const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt); + const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt); try { return { type: 'joinCreateGeneral', @@ -377,6 +378,7 @@ async function handleJoinCreateGeneral( ...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}), }, acceptedAt, + turnScheduleAt, operationalAcceptedAt, })), }; @@ -455,6 +457,7 @@ async function handleSelectPoolCreate( : command.acceptedGameAt !== undefined ? new Date(command.acceptedGameAt) : ctx.world.getGameNow(operationalAcceptedAt); + const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt); try { return { type: 'selectPoolCreate', @@ -471,6 +474,7 @@ async function handleSelectPoolCreate( ...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}), ...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}), now: acceptedAt, + turnScheduleAt, operationalAcceptedAt, })), }; diff --git a/app/game-engine/test/joinCreateGeneralService.test.ts b/app/game-engine/test/joinCreateGeneralService.test.ts index 8729f2ad..1e6953ea 100644 --- a/app/game-engine/test/joinCreateGeneralService.test.ts +++ b/app/game-engine/test/joinCreateGeneralService.test.ts @@ -80,6 +80,27 @@ describe('generic join deterministic contracts', () => { ]); }); + it('moves an inherited turn phase past the runnable opening boundary', () => { + const rng = { + nextRangeInt(min: number) { + return min; + }, + }; + const openingGameAt = new Date('2026-07-30T10:00:00.000Z'); + const preopenCursor = new Date(openingGameAt.getTime() - 30 * 60_000); + + const turnTime = resolveJoinTurnTime( + rng, + { tickSeconds: 60 } as Parameters[1], + openingGameAt, + preopenCursor, + 0 + ); + + expect(turnTime.getTime()).toBeGreaterThan(openingGameAt.getTime()); + expect((turnTime.getTime() - preopenCursor.getTime()) % 60_000).toBe(0); + }); + it('uses the HiDCHe product name without the legacy PHP runtime label', () => { expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^'); expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP'); diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index 660ade94..bad95498 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -174,6 +174,26 @@ describe('runtime clock shift', () => { expect(world.getGameClockState().wallAnchor).toEqual(resumedAt); }); + it('keeps runnable general scheduling at the future opening anchor during PREOPEN', () => { + const gameBase = new Date('2026-07-30T10:00:00.000Z'); + const openAt = new Date('2026-09-02T23:30:00.000Z'); + const world = buildWorld({ + clockBaseTime: gameBase, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: openAt, + lastTurnTick: 0, + }); + const preopenAt = new Date('2026-09-02T23:03:00.000Z'); + + expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime()); + expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase); + expect(world.getRunnableGameNow(openAt)).toEqual(gameBase); + expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual( + new Date(gameBase.getTime() + 60_000) + ); + }); + it.each([ [5, 6], [10, 3], diff --git a/app/game-engine/test/selectPoolReservation.test.ts b/app/game-engine/test/selectPoolReservation.test.ts index 75cb562d..2883a82d 100644 --- a/app/game-engine/test/selectPoolReservation.test.ts +++ b/app/game-engine/test/selectPoolReservation.test.ts @@ -4,7 +4,11 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { reserveSelectionPool, resolveSelectionPoolUserIcon } from '../src/turn/selectPoolService.js'; +import { + buildInitialTurnTime, + reserveSelectionPool, + resolveSelectionPoolUserIcon, +} from '../src/turn/selectPoolService.js'; import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; interface TestPoolRow { @@ -28,6 +32,30 @@ interface PoolWhere { const acceptedAt = new Date('0200-05-01T00:00:00.000Z'); +describe('selection-pool initial turn scheduling', () => { + it('does not inherit a turntime base from before the opening boundary', () => { + const openingGameAt = new Date('0200-01-01T00:00:00.000Z'); + const preopenGameAt = new Date(openingGameAt.getTime() - 30 * 60_000); + const rng = { + nextRangeInt(min: number) { + return min; + }, + }; + + const turnTime = buildInitialTurnTime( + rng, + { + tickSeconds: 300, + meta: { turntime: preopenGameAt.toISOString() }, + } as unknown as Parameters[1], + preopenGameAt, + openingGameAt + ); + + expect(turnTime.getTime()).toBeGreaterThanOrEqual(openingGameAt.getTime()); + }); +}); + const buildRows = (): TestPoolRow[] => Array.from({ length: 29 }, (_, index) => { const uniqueName = `P${String(index + 1).padStart(2, '0')}`;