fix: NPC 빙의 seed를 Ref 논리 tick과 일치시킨다
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user