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)'
);
});
});