fix: NPC 빙의 seed를 Ref 논리 tick과 일치시킨다
This commit is contained in:
@@ -589,6 +589,12 @@ export const joinRouter = router({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
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({
|
return await reserveNpcPossessionCandidates({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
worldState,
|
worldState,
|
||||||
@@ -597,6 +603,7 @@ export const joinRouter = router({
|
|||||||
refresh: input.refresh,
|
refresh: input.refresh,
|
||||||
keepIds: input.keepIds,
|
keepIds: input.keepIds,
|
||||||
now: gameTime.now,
|
now: gameTime.now,
|
||||||
|
acceptedGameTick: gameTime.tick,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NpcPossessionError) {
|
if (error instanceof NpcPossessionError) {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
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 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 {
|
import {
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
createGamePostgresConnector,
|
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();
|
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
|
||||||
expect(config.npcPossession).toEqual({ enabled: true });
|
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([
|
const [first, concurrentSameOwner] = await Promise.all([
|
||||||
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
|
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
|
||||||
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).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).toBeGreaterThan(0);
|
||||||
expect(first.candidates.length).toBeLessThanOrEqual(5);
|
expect(first.candidates.length).toBeLessThanOrEqual(5);
|
||||||
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
|
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.pickMoreSeconds).toBe(0);
|
||||||
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
||||||
const rows = await db.general.findMany({
|
const rows = await db.general.findMany({
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ interface NpcSelectionTokenRow {
|
|||||||
nonce: number;
|
nonce: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
|
|
||||||
const VALID_SECONDS = 90;
|
const VALID_SECONDS = 90;
|
||||||
const PICK_MORE_SECONDS = 10;
|
const PICK_MORE_SECONDS = 10;
|
||||||
const KEEP_COUNT = 3;
|
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 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 = (
|
export const buildNpcSelectionTokenSeed = (
|
||||||
hiddenSeed: string | number,
|
hiddenSeed: string | number,
|
||||||
ownerIdentity: string | number,
|
ownerIdentity: string | number,
|
||||||
now: Date
|
acceptedGameTick: number
|
||||||
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, formatLegacySeedTime(now));
|
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick);
|
||||||
|
|
||||||
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||||
const meta = asRecord(worldState.meta);
|
const meta = asRecord(worldState.meta);
|
||||||
@@ -298,11 +289,15 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
refresh?: boolean;
|
refresh?: boolean;
|
||||||
keepIds?: number[];
|
keepIds?: number[];
|
||||||
now?: Date;
|
now?: Date;
|
||||||
|
acceptedGameTick: number;
|
||||||
selectionObserver?: NpcPossessionSelectionObserver;
|
selectionObserver?: NpcPossessionSelectionObserver;
|
||||||
}): Promise<NpcPossessionReservation> => {
|
}): Promise<NpcPossessionReservation> => {
|
||||||
const { db, worldState, userId } = options;
|
const { db, worldState, userId } = options;
|
||||||
requireNpcPossessionWorld(worldState);
|
requireNpcPossessionWorld(worldState);
|
||||||
const now = truncateToSeconds(options.now ?? new Date());
|
const now = truncateToSeconds(options.now ?? new Date());
|
||||||
|
if (!Number.isSafeInteger(options.acceptedGameTick)) {
|
||||||
|
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.');
|
||||||
|
}
|
||||||
await lockNpcPossession(db, userId);
|
await lockNpcPossession(db, userId);
|
||||||
|
|
||||||
if (await db.general.findFirst({ where: { userId }, select: { id: true } })) {
|
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)))
|
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
|
||||||
);
|
);
|
||||||
const selectionRng = new LiteHashDRBG(
|
const selectionRng = new LiteHashDRBG(
|
||||||
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, now)
|
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick)
|
||||||
);
|
);
|
||||||
const rng = options.selectionObserver?.onRandomDraw
|
const rng = options.selectionObserver?.onRandomDraw
|
||||||
? new ObservedRandUtil(selectionRng, 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';
|
import { buildNpcSelectionTokenSeed } from '../src/turn/npcPossessionService.js';
|
||||||
|
|
||||||
describe('NPC possession legacy token contracts', () => {
|
describe('NPC possession legacy token contracts', () => {
|
||||||
it('builds the Ref SelectNPCToken seed from the Seoul whole-second timestamp', () => {
|
it('builds the Ref SelectNPCToken seed from the accepted game tick', () => {
|
||||||
expect(buildNpcSelectionTokenSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe(
|
expect(buildNpcSelectionTokenSeed('seed', 42, 72_000_001)).toBe(
|
||||||
'str(4,seed)|str(14,SelectNPCToken)|int(42)|str(19,2026-07-31 08:59:58)'
|
'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 reserved = new Set([...testCase.reservedIds, ...(testCase.boundaryReservedIds ?? [])]);
|
||||||
const candidates = fixture.candidates
|
const candidates = fixture.candidates
|
||||||
.filter(({ id }) => !reserved.has(id))
|
.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 rng = new TracingRandUtil(new LiteHashDRBG(seed));
|
||||||
const draws: number[] = [];
|
const draws: number[] = [];
|
||||||
const picked = chooseNpcPossessionCandidates(candidates, kept, rng, (selectedId) => {
|
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', () => {
|
integration('NPC possession selector Ref differential', () => {
|
||||||
if (!workspaceRoot || !databaseUrl || !referenceEnabled) {
|
if (!workspaceRoot || !databaseUrl || !referenceEnabled) {
|
||||||
return;
|
return;
|
||||||
@@ -317,7 +338,10 @@ integration('NPC possession selector Ref differential', () => {
|
|||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
const runCoreReservationCase = async (testCase: FixtureCase): Promise<CoreReservationTrace> =>
|
const runCoreReservationCase = async (
|
||||||
|
testCase: FixtureCase,
|
||||||
|
acceptedGameTick: number
|
||||||
|
): Promise<CoreReservationTrace> =>
|
||||||
db.$transaction(async (transaction) => {
|
db.$transaction(async (transaction) => {
|
||||||
await transaction.npcSelectionToken.deleteMany();
|
await transaction.npcSelectionToken.deleteMany();
|
||||||
const validUntil = new Date('2099-12-31T23:59:59.000Z');
|
const validUntil = new Date('2099-12-31T23:59:59.000Z');
|
||||||
@@ -383,6 +407,7 @@ integration('NPC possession selector Ref differential', () => {
|
|||||||
refresh: hasPreviousToken,
|
refresh: hasPreviousToken,
|
||||||
keepIds: testCase.keepIds,
|
keepIds: testCase.keepIds,
|
||||||
now: acceptedAt(fixture),
|
now: acceptedAt(fixture),
|
||||||
|
acceptedGameTick,
|
||||||
selectionObserver: {
|
selectionObserver: {
|
||||||
onRandomDraw: (value) => randomDraws.push(value),
|
onRandomDraw: (value) => randomDraws.push(value),
|
||||||
onCandidateDraw: (selectedId) => draws.push(Number(selectedId)),
|
onCandidateDraw: (selectedId) => draws.push(Number(selectedId)),
|
||||||
@@ -427,11 +452,18 @@ integration('NPC possession selector Ref differential', () => {
|
|||||||
);
|
);
|
||||||
expect(firstReference.cases).toHaveLength(fixture.cases.length);
|
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()) {
|
for (const [index, referenceCase] of firstReference.cases.entries()) {
|
||||||
const testCase = fixture.cases[index]!;
|
const testCase = fixture.cases[index]!;
|
||||||
const coreKernel = coreKernelCases[index]!;
|
const coreKernel = coreKernelCases[index]!;
|
||||||
const coreReservation = await runCoreReservationCase(testCase);
|
const coreReservation = await runCoreReservationCase(testCase, referenceAcceptedGameTick);
|
||||||
expect(referenceCase.name).toBe(coreKernel.name);
|
expect(referenceCase.name).toBe(coreKernel.name);
|
||||||
expect(referenceCase.selectionStateUnchanged).toBe(true);
|
expect(referenceCase.selectionStateUnchanged).toBe(true);
|
||||||
expect(referenceCase.cancelled).toBe(coreKernel.cancelled);
|
expect(referenceCase.cancelled).toBe(coreKernel.cancelled);
|
||||||
|
|||||||
Reference in New Issue
Block a user