fix: 장수 재생성 seed를 장수 번호로 회전
정지된 논리 게임 시각에서도 삭제 후 재생성 결과가 반복되지 않도록 단조 증가 장수 번호를 seed nonce로 추가한다. 검증 실패와 daemon 재시도의 allocator 결정성은 유지한다.
This commit is contained in:
@@ -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<void> => {
|
||||
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')
|
||||
: [];
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user