diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 01a09ffd..37a503f2 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -589,6 +589,12 @@ export const joinRouter = router({ } try { const gameTime = await loadCurrentGameTime(ctx.db); + if (gameTime.tick === null) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Game clock is not initialized.', + }); + } return await reserveNpcPossessionCandidates({ db: ctx.db, worldState, @@ -597,6 +603,7 @@ export const joinRouter = router({ refresh: input.refresh, keepIds: input.keepIds, now: gameTime.now, + acceptedGameTick: gameTime.tick, }); } catch (error) { if (error instanceof NpcPossessionError) { diff --git a/app/game-api/test/npcPossession.integration.test.ts b/app/game-api/test/npcPossession.integration.test.ts index 3a9f9f6c..dc5cf994 100644 --- a/app/game-api/test/npcPossession.integration.test.ts +++ b/app/game-api/test/npcPossession.integration.test.ts @@ -1,7 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; -import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine'; +import { + buildNpcSelectionTokenSeed, + createTurnDaemonRuntime, + seedScenarioToDatabase, + type TurnDaemonRuntime, +} from '@sammo-ts/game-engine'; import { acquireGameSchemaAdvisoryXactLock, createGamePostgresConnector, @@ -199,6 +205,32 @@ integration('mode 1 NPC possession through token reservation and the durable dae const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig(); expect(config.npcPossession).toEqual({ enabled: true }); + const worldState = await db.worldState.findFirstOrThrow(); + const acceptedGameTick = Number(worldState.clockTick); + const hiddenSeed = asRecord(worldState.meta).hiddenSeed; + expect(Number.isSafeInteger(acceptedGameTick)).toBe(true); + if (typeof hiddenSeed !== 'string' && typeof hiddenSeed !== 'number') { + throw new Error('NPC possession integration hidden seed is missing'); + } + const selectable = await db.general.findMany({ + where: { userId: null, npcState: 2 }, + orderBy: { id: 'asc' }, + select: { id: true, leadership: true, strength: true, intel: true }, + }); + const weights = Object.fromEntries( + selectable.map((candidate) => [ + String(candidate.id), + Math.pow(candidate.leadership + candidate.strength + candidate.intel, 1.5), + ]) + ); + const rng = new RandUtil( + new LiteHashDRBG(buildNpcSelectionTokenSeed(hiddenSeed, 7_701, acceptedGameTick)) + ); + const expectedCandidateIds = new Set(); + while (expectedCandidateIds.size < Math.min(5, selectable.length)) { + expectedCandidateIds.add(Number(rng.choiceUsingWeight(weights))); + } + const [first, concurrentSameOwner] = await Promise.all([ appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}), appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}), @@ -208,6 +240,9 @@ integration('mode 1 NPC possession through token reservation and the durable dae expect(first.candidates.length).toBeGreaterThan(0); expect(first.candidates.length).toBeLessThanOrEqual(5); expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length); + expect(first.candidates.map(({ id }) => id).sort((left, right) => left - right)).toEqual( + [...expectedCandidateIds].sort((left, right) => left - right) + ); expect(first.pickMoreSeconds).toBe(0); expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true); const rows = await db.general.findMany({ diff --git a/app/game-engine/src/turn/npcPossessionService.ts b/app/game-engine/src/turn/npcPossessionService.ts index 96fc7ce0..8af9b0f4 100644 --- a/app/game-engine/src/turn/npcPossessionService.ts +++ b/app/game-engine/src/turn/npcPossessionService.ts @@ -89,7 +89,6 @@ interface NpcSelectionTokenRow { nonce: number; } -const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000; const VALID_SECONDS = 90; const PICK_MORE_SECONDS = 10; const KEEP_COUNT = 3; @@ -103,19 +102,11 @@ const fail = (code: NpcPossessionErrorCode, message: string): never => { const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTime() / 1000) * 1000); -const formatLegacySeedTime = (value: Date): string => { - const pad = (part: number): string => String(part).padStart(2, '0'); - const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS); - return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad( - koreaTime.getUTCDate() - )} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`; -}; - export const buildNpcSelectionTokenSeed = ( hiddenSeed: string | number, ownerIdentity: string | number, - now: Date -): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, formatLegacySeedTime(now)); + acceptedGameTick: number +): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick); const readHiddenSeed = (worldState: WorldStateRow): string | number => { const meta = asRecord(worldState.meta); @@ -298,11 +289,15 @@ export const reserveNpcPossessionCandidates = async (options: { refresh?: boolean; keepIds?: number[]; now?: Date; + acceptedGameTick: number; selectionObserver?: NpcPossessionSelectionObserver; }): Promise => { const { db, worldState, userId } = options; requireNpcPossessionWorld(worldState); const now = truncateToSeconds(options.now ?? new Date()); + if (!Number.isSafeInteger(options.acceptedGameTick)) { + fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.'); + } await lockNpcPossession(db, userId); if (await db.general.findFirst({ where: { userId }, select: { id: true } })) { @@ -402,7 +397,7 @@ export const reserveNpcPossessionCandidates = async (options: { generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId))) ); const selectionRng = new LiteHashDRBG( - buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, now) + buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick) ); const rng = options.selectionObserver?.onRandomDraw ? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw) diff --git a/app/game-engine/test/npcPossessionService.test.ts b/app/game-engine/test/npcPossessionService.test.ts index 17d94061..8c06d961 100644 --- a/app/game-engine/test/npcPossessionService.test.ts +++ b/app/game-engine/test/npcPossessionService.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'; import { buildNpcSelectionTokenSeed } from '../src/turn/npcPossessionService.js'; describe('NPC possession legacy token contracts', () => { - it('builds the Ref SelectNPCToken seed from the Seoul whole-second timestamp', () => { - expect(buildNpcSelectionTokenSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe( - 'str(4,seed)|str(14,SelectNPCToken)|int(42)|str(19,2026-07-31 08:59:58)' + it('builds the Ref SelectNPCToken seed from the accepted game tick', () => { + expect(buildNpcSelectionTokenSeed('seed', 42, 72_000_001)).toBe( + 'str(4,seed)|str(14,SelectNPCToken)|int(42)|int(72000001)' ); }); }); diff --git a/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts index 37b71b55..dc210fe1 100644 --- a/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts +++ b/tools/integration-tests/test/npcPossessionSelectionReference.integration.test.ts @@ -174,7 +174,7 @@ class TracingRandUtil extends RandUtil { } } -const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase): KernelTrace => { +const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase, acceptedGameTick: number): KernelTrace => { const reserved = new Set([...testCase.reservedIds, ...(testCase.boundaryReservedIds ?? [])]); const candidates = fixture.candidates .filter(({ id }) => !reserved.has(id)) @@ -200,7 +200,7 @@ const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase): KernelTrace }; } - const seed = buildNpcSelectionTokenSeed(fixture.hiddenSeed, fixture.owner, acceptedAt(fixture)); + const seed = buildNpcSelectionTokenSeed(fixture.hiddenSeed, fixture.owner, acceptedGameTick); const rng = new TracingRandUtil(new LiteHashDRBG(seed)); const draws: number[] = []; const picked = chooseNpcPossessionCandidates(candidates, kept, rng, (selectedId) => { @@ -227,6 +227,27 @@ const assertDedicatedDatabase = (rawUrl: string): void => { } }; +const readReferenceAcceptedGameTick = (trace: ReferenceTrace): number => { + const ticks = new Set( + trace.cases.flatMap(({ seed }) => { + if (seed === null) return []; + const match = seed.match(/\|int\((-?\d+)\)$/); + if (!match) { + throw new Error(`Ref SelectNPCToken seed does not end with an integer game tick: ${seed}`); + } + const tick = Number(match[1]); + if (!Number.isSafeInteger(tick)) { + throw new Error(`Ref SelectNPCToken tick is outside the safe integer range: ${match[1]}`); + } + return [tick]; + }) + ); + if (ticks.size !== 1) { + throw new Error(`Ref comparison returned ${ticks.size} distinct accepted game ticks`); + } + return [...ticks][0]!; +}; + integration('NPC possession selector Ref differential', () => { if (!workspaceRoot || !databaseUrl || !referenceEnabled) { return; @@ -317,7 +338,10 @@ integration('NPC possession selector Ref differential', () => { await closeDb?.(); }); - const runCoreReservationCase = async (testCase: FixtureCase): Promise => + const runCoreReservationCase = async ( + testCase: FixtureCase, + acceptedGameTick: number + ): Promise => db.$transaction(async (transaction) => { await transaction.npcSelectionToken.deleteMany(); const validUntil = new Date('2099-12-31T23:59:59.000Z'); @@ -383,6 +407,7 @@ integration('NPC possession selector Ref differential', () => { refresh: hasPreviousToken, keepIds: testCase.keepIds, now: acceptedAt(fixture), + acceptedGameTick, selectionObserver: { onRandomDraw: (value) => randomDraws.push(value), onCandidateDraw: (selectedId) => draws.push(Number(selectedId)), @@ -427,11 +452,18 @@ integration('NPC possession selector Ref differential', () => { ); expect(firstReference.cases).toHaveLength(fixture.cases.length); - const coreKernelCases = fixture.cases.map((testCase) => runCoreKernelCase(fixture, testCase)); + // The shared Ref database and disposable Core scenario intentionally have + // different clock bases. This selector differential isolates RNG by injecting + // the exact safe integer tick observed at the Ref endpoint into both Core paths; + // it does not claim clock-base parity between the two fixtures. + const referenceAcceptedGameTick = readReferenceAcceptedGameTick(firstReference); + const coreKernelCases = fixture.cases.map((testCase) => + runCoreKernelCase(fixture, testCase, referenceAcceptedGameTick) + ); for (const [index, referenceCase] of firstReference.cases.entries()) { const testCase = fixture.cases[index]!; const coreKernel = coreKernelCases[index]!; - const coreReservation = await runCoreReservationCase(testCase); + const coreReservation = await runCoreReservationCase(testCase, referenceAcceptedGameTick); expect(referenceCase.name).toBe(coreKernel.name); expect(referenceCase.selectionStateUnchanged).toBe(true); expect(referenceCase.cancelled).toBe(coreKernel.cancelled);