diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index a10488fa..16fa7d6e 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -167,8 +167,9 @@ const readHiddenSeed = (worldState: WorldStateRow): string | number => { export const buildJoinCreateGeneralSeed = ( hiddenSeed: string | number, ownerIdentity: string | number, - acceptedTick: number -): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, acceptedTick); + acceptedTick: number, + generalId: number +): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, acceptedTick, generalId); const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise => { await acquireGameSchemaAdvisoryXactLock(db, `join-create:user:${userId}`); @@ -633,12 +634,6 @@ export const createGeneralFromJoin = async (options: { fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.'); } - const hiddenSeed = readHiddenSeed(worldState); - const rng = new RandUtil( - new LiteHashDRBG( - buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt)) - ) - ); const currentGenius = Math.max( 0, Math.floor(asNumber(worldMeta.genius, asNumber(configConst.defaultMaxGenius, DEFAULT_MAX_GENIUS))) @@ -646,6 +641,23 @@ export const createGeneralFromJoin = async (options: { if (input.inheritSpecial !== undefined && currentGenius === 0) { fail('PRECONDITION_FAILED', '이미 천재가 모두 나타났습니다. 다시 가입해주세요!'); } + + // PREOPEN에서는 logical game tick이 멈춰 있으므로 같은 사용자가 삭제 후 + // 재생성하면 Ref의 wall-clock seed와 달리 완전히 같은 난수가 반복될 수 있다. + // 검증을 모두 통과한 생성에만 배정되는 단조 증가 장수 번호를 nonce로 넣어 + // daemon 재시도는 결정적으로 유지하면서 후속 생성의 seed는 회전시킨다. + const hiddenSeed = readHiddenSeed(worldState); + const generalId = world.getNextGeneralId(); + const rng = new RandUtil( + new LiteHashDRBG( + buildJoinCreateGeneralSeed( + hiddenSeed, + input.seedOwnerIdentity, + world.dateToGameTick(acceptedAt), + generalId + ) + ) + ); const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01); const genius = geniusRequested && currentGenius > 0; @@ -701,7 +713,6 @@ export const createGeneralFromJoin = async (options: { ); const personality = input.character === 'Random' ? rng.choice([...JOIN_PERSONALITY_TRAIT_KEYS]) : input.character; const affinity = rng.nextRangeInt(1, 150); - const generalId = world.getNextGeneralId(); let obfuscatedNamePool = Array.isArray(worldMeta.obfuscatedNamePool) ? worldMeta.obfuscatedNamePool.filter((value): value is string => typeof value === 'string') : []; diff --git a/app/game-engine/test/joinCreateGeneralService.test.ts b/app/game-engine/test/joinCreateGeneralService.test.ts index c5997376..8729f2ad 100644 --- a/app/game-engine/test/joinCreateGeneralService.test.ts +++ b/app/game-engine/test/joinCreateGeneralService.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; + import { buildJoinCreateGeneralSeed, cutJoinTurnTime, @@ -8,13 +10,41 @@ import { resolveJoinTurnTime, } from '../src/turn/joinCreateGeneralService.js'; -describe('generic join legacy time contracts', () => { - it('builds the Ref MakeGeneral seed from the logical game tick', () => { - expect(buildJoinCreateGeneralSeed('seed', 42, 72_000_000)).toBe( - 'str(4,seed)|str(11,MakeGeneral)|int(42)|int(72000000)' +describe('generic join deterministic contracts', () => { + it('builds a deterministic MakeGeneral seed with the allocated general number', () => { + expect(buildJoinCreateGeneralSeed('seed', 42, 72_000_000, 17)).toBe( + 'str(4,seed)|str(11,MakeGeneral)|int(42)|int(72000000)|int(17)' ); }); + it('rotates the MakeGeneral seed when the same owner recreates at a frozen logical tick', () => { + const first = buildJoinCreateGeneralSeed('seed', 42, 72_000_000, 17); + const recreated = buildJoinCreateGeneralSeed('seed', 42, 72_000_000, 18); + + expect(recreated).not.toBe(first); + expect(buildJoinCreateGeneralSeed('seed', 42, 72_000_000, 17)).toBe(first); + }); + + it('produces distinct fixed-seed draw streams for consecutive general numbers', () => { + const draw = (generalId: number): number[] => { + const rng = new RandUtil( + new LiteHashDRBG(buildJoinCreateGeneralSeed('seed', 42, 72_000_000, generalId)) + ); + return [ + rng.nextRangeInt(0, 5), + rng.nextRangeInt(3, 5), + rng.nextRangeInt(0, 1), + rng.nextRangeInt(0, 299), + rng.nextRangeInt(0, 999_999), + rng.nextRangeInt(1, 150), + ]; + }; + + expect(draw(17)).toEqual([4, 5, 0, 153, 734_806, 128]); + expect(draw(18)).toEqual([4, 4, 0, 49, 230_491, 44]); + expect(draw(17)).toEqual(draw(17)); + }); + it('aligns a 120-minute turn from the Ref Asia/Seoul previous-day 01:00 anchor', () => { expect(cutJoinTurnTime(new Date('2026-07-30T03:34:56.789Z'), 120 * 60).toISOString()).toBe( '2026-07-30T02:00:00.000Z'