fix: NPC 빙의 seed를 Ref 논리 tick과 일치시킨다

This commit is contained in:
2026-08-24 09:18:08 +00:00
parent 90d43547fe
commit 821b8a0723
5 changed files with 90 additions and 21 deletions
+7
View File
@@ -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) {
@@ -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<number>();
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({
@@ -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<NpcPossessionReservation> => {
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)
@@ -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)'
);
});
});
@@ -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<CoreReservationTrace> =>
const runCoreReservationCase = async (
testCase: FixtureCase,
acceptedGameTick: number
): Promise<CoreReservationTrace> =>
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);