diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index d9e1dd0..19b9937 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -1,6 +1,12 @@ import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra'; import { asRecord } from '@sammo-ts/common'; -import { buildScenarioBootstrap, type GeneralMeta, type ScenarioBootstrapWarning, type WorldSeedPayload } from '@sammo-ts/logic'; +import { + buildScenarioBootstrap, + resolveScenarioGeneralDeathMonth, + type GeneralMeta, + type ScenarioBootstrapWarning, + type WorldSeedPayload, +} from '@sammo-ts/logic'; import type { MapLoaderOptions } from './mapLoader.js'; import { loadMapDefinitionByName } from './mapLoader.js'; @@ -150,12 +156,12 @@ const resolveKillturnFromDeathYear = ( currentYear: number, currentMonth: number, deathYear: number, + deathMonth: number, fallback: number ): number => { if (!Number.isFinite(deathYear) || deathYear <= 0) { return fallback; } - const deathMonth = Math.floor(Math.random() * 12) + 1; const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth); return Math.max(diff, 0); }; @@ -442,15 +448,32 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom delete meta.deadYear; const fallbackKillturn = typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) ? meta.killturn : 0; + const deathMonth = + typeof meta.deathMonth === 'number' && + Number.isInteger(meta.deathMonth) && + meta.deathMonth >= 1 && + meta.deathMonth <= 12 + ? meta.deathMonth + : resolveScenarioGeneralDeathMonth({ + scenarioTitle: String(seed.scenarioMeta?.title ?? ''), + startYear: seed.scenarioMeta?.startYear ?? null, + contextLabel: + typeof meta.source === 'string' ? meta.source : 'general', + generalId: general.id, + generalName: general.name, + deathYear: general.deathYear, + }); const killturn = resolveKillturnFromDeathYear( startState.currentYear, startState.currentMonth, general.deathYear, + deathMonth, fallbackKillturn ); return { ...meta, killturn, + deathMonth, npcType: general.npcType, crewTypeId: general.crewTypeId, } satisfies GeneralMeta; diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index dc10235..68bfbbe 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -27,17 +27,6 @@ const resolveServerId = (world: InMemoryTurnWorld): string | null => { return typeof value === 'string' && value !== '' ? value : null; }; -const shuffleLegacy = (values: T[]): T[] => { - const result = [...values]; - // ref의 follower 병종 숙련 배열은 action DRBG가 아니라 PHP process-global - // shuffle()로 섞인다. - for (let index = result.length - 1; index > 0; index -= 1) { - const target = Math.floor(Math.random() * (index + 1)); - [result[index], result[target]] = [result[target]!, result[index]!]; - } - return result; -}; - const createTurnTime = (rng: RandUtil, environment: MonthlyEventEnvironment, tickSeconds: number): Date => { const turnMinutes = tickSeconds / 60; if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) { @@ -235,6 +224,17 @@ export const createRaiseInvaderHandler = (options: { simpleSerialize(resolveHiddenSeed(world), 'RaiseInvader', environment.year, environment.month) ) ); + const dexShuffleRng = new RandUtil( + new LiteHashDRBG( + simpleSerialize( + resolveHiddenSeed(world), + 'RaiseInvader', + environment.year, + environment.month, + 'martialDex' + ) + ) + ); for (const nation of world.listNations()) { world.updateNation(nation.id, { @@ -363,7 +363,7 @@ export const createRaiseInvaderHandler = (options: { const mainStat = rng.nextRangeInt(toInteger(specAverage * 1.2), toInteger(specAverage * 1.4)); const subStat = specAverage * 3 - leadership - mainStat; const isWarrior = rng.nextBit(); - const martialDex = isWarrior ? shuffleLegacy([dex * 2, dex, dex]) : [dex, dex, dex]; + const martialDex = isWarrior ? dexShuffleRng.shuffle([dex * 2, dex, dex]) : [dex, dex, dex]; createInvaderGeneral({ world, reservedTurns: options.reservedTurns, diff --git a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts index a437ed6..fdd5fdb 100644 --- a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts +++ b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts @@ -111,17 +111,6 @@ const calculateAverageCity = (rng: RandUtil, cities: City[]): CityValues => { ) as CityValues; }; -const shuffleLegacy = (values: T[]): T[] => { - const result = [...values]; - // ref Util::shuffle_assoc()도 action DRBG가 아닌 PHP의 process-global - // shuffle()을 사용한다. - for (let index = result.length - 1; index > 0; index -= 1) { - const target = Math.floor(Math.random() * (index + 1)); - [result[index], result[target]] = [result[target]!, result[index]!]; - } - return result; -}; - const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; @@ -245,9 +234,20 @@ export const createRaiseNpcNationHandler = (options: { simpleSerialize(resolveHiddenSeed(world), 'RaiseNPCNation', environment.year, environment.month) ) ); + const cityShuffleRng = new RandUtil( + new LiteHashDRBG( + simpleSerialize( + resolveHiddenSeed(world), + 'RaiseNPCNation', + environment.year, + environment.month, + 'emptyCities' + ) + ) + ); const averageCity = calculateAverageCity(rng, targetCities); const occupiedCityIds = targetCities.filter((city) => city.nationId !== 0).map((city) => city.id); - const emptyCities = shuffleLegacy(targetCities.filter((city) => city.nationId === 0)); + const emptyCities = cityShuffleRng.shuffle(targetCities.filter((city) => city.nationId === 0)); const activeNations = world.listNations().filter((nation) => nation.id !== 0 && nation.level > 0); const generalCounts = activeNations.map( (nation) => world.listGenerals().filter((general) => general.nationId === nation.id).length diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index 750d295..d3253b8 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -57,14 +57,16 @@ const readPattern = (world: InMemoryTurnWorld, config: Record): ); }; -const shuffledDefaultPattern = (): number[] => { - const pattern = [0, 0, 1, 2, 3]; - for (let index = pattern.length - 1; index > 0; index -= 1) { - const swapIndex = Math.floor(Math.random() * (index + 1)); - [pattern[index], pattern[swapIndex]] = [pattern[swapIndex]!, pattern[index]!]; - } - return pattern; -}; +const shuffledDefaultPattern = ( + hiddenSeed: string | number, + previousYear: number, + previousMonth: number +): number[] => + new RandUtil( + new LiteHashDRBG( + simpleSerialize(hiddenSeed, 'monthly', previousYear, previousMonth, 'tournamentPattern') + ) + ).shuffle([0, 0, 1, 2, 3]); export const createTournamentAutoStartHandler = (options: { profileName: string; @@ -112,7 +114,10 @@ export const createTournamentAutoStartHandler = (options: { } const pattern = readPattern(world, config); - const resolvedPattern = pattern.length > 0 ? pattern : shuffledDefaultPattern(); + const resolvedPattern = + pattern.length > 0 + ? pattern + : shuffledDefaultPattern(hiddenSeed, context.previousYear, context.previousMonth); const type = resolvedPattern.pop() ?? 0; world.updateWorldMeta({ tournamentPattern: resolvedPattern }); const now = options.now?.() ?? new Date(); diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index 380c3d1..12f8f28 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { City, Nation } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; @@ -166,6 +166,16 @@ const buildHarness = (options?: { }; describe('invader monthly actions', () => { + beforeEach(() => { + vi.spyOn(Math, 'random').mockImplementation(() => { + throw new Error('monthly invader actions must not use Math.random'); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => { const harness = buildHarness(); const handler = createRaiseInvaderHandler({ diff --git a/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts index 709e712..a3724c8 100644 --- a/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts +++ b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PERSONALITY_TRAIT_KEYS, type City, type MapDefinition, type Nation } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; @@ -192,6 +192,16 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi }; describe('RaiseNPCNation monthly action', () => { + beforeEach(() => { + vi.spyOn(Math, 'random').mockImplementation(() => { + throw new Error('RaiseNPCNation must not use Math.random'); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('creates only distance-qualified NPC nations and initializes their ruler and turns', async () => { const { world, reservedTurns, handler, environment } = buildHarness(); diff --git a/app/game-engine/test/tournamentAutoStart.test.ts b/app/game-engine/test/tournamentAutoStart.test.ts index 7a7ced0..513b150 100644 --- a/app/game-engine/test/tournamentAutoStart.test.ts +++ b/app/game-engine/test/tournamentAutoStart.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { RedisConnector } from '@sammo-ts/infra'; import type { Nation } from '@sammo-ts/logic'; @@ -143,4 +143,86 @@ describe('monthly tournament auto start', () => { expect(consumed).toEqual([false]); expect(world.peekDirtyState().logs).toEqual([]); }); + + it('derives an empty tournament pattern from the monthly seed without Math.random', async () => { + const run = async () => { + const state: TurnWorldState = { + id: 1, + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0193-01-01T00:00:00.000Z'), + meta: { hiddenSeed: 'monthly-post-tail-2' }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { + total: 300, + min: 10, + max: 100, + npcTotal: 150, + npcMax: 50, + npcMin: 10, + chiefMin: 70, + }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { id: 'test', name: 'test', cities: [] }, + diplomacy: [], + events: [], + initialEvents: [], + generals: [], + cities: [], + nations: [buildNation(1), buildNation(2)], + troops: [], + }; + const values = new Map(); + const redis = { + get: async (key: string) => values.get(key) ?? null, + set: async (key: string, value: string) => { + values.set(key, value); + return 'OK'; + }, + } as unknown as RedisConnector['client']; + let world: InMemoryTurnWorld | null = null; + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createTournamentAutoStartHandler({ + profileName: 'test', + getWorld: () => world, + getRedisClient: () => redis, + getWorldConfig: () => ({ tournamentTrig: true }), + getNationPowerRollCount: () => 2, + now: () => new Date('2026-07-25T00:00:00.000Z'), + }), + }); + + await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); + + return { + tournamentState: JSON.parse(values.get('sammo:test:tournament:state') ?? '{}') as { + type?: number; + }, + remainingPattern: world.getState().meta.tournamentPattern, + }; + }; + + const random = vi.spyOn(Math, 'random').mockImplementation(() => { + throw new Error('tournament fallback must not use Math.random'); + }); + try { + const first = await run(); + const second = await run(); + expect(second).toEqual(first); + expect(first).toEqual({ + tournamentState: expect.objectContaining({ type: 1 }), + remainingPattern: [2, 0, 0, 3], + }); + } finally { + random.mockRestore(); + } + }); }); diff --git a/docs/architecture/legacy-engine-events.md b/docs/architecture/legacy-engine-events.md index 176fbb0..9ddcd99 100644 --- a/docs/architecture/legacy-engine-events.md +++ b/docs/architecture/legacy-engine-events.md @@ -118,6 +118,30 @@ Dynamic event actions can use deterministic RNG by constructing `LiteHashDRBG` with `UniqueConst::$hiddenSeed` and an event-specific tag. Examples include `RandomizeCityTradeRate` and `UpdateNationLevel`. +core2026의 authoritative game-state 계산에서는 `Math.random()`을 사용하지 +않는다. 레거시 PHP 전역 `shuffle()`을 사용하던 다음 경로도 입력별 독립 +`LiteHashDRBG` substream으로 고정한다. + +- `RaiseNPCNation`: `hiddenSeed, "RaiseNPCNation", year, month, + "emptyCities"` +- `RaiseInvader`: `hiddenSeed, "RaiseInvader", year, month, + "martialDex"` +- 빈 tournament pattern: `hiddenSeed, "monthly", previousYear, + previousMonth, "tournamentPattern"` +- scenario general 사망월: scenario title/start year, source group, + general id/name/death year와 `"deathMonth"` + +독립 substream을 쓰는 이유는 레거시 전역 shuffle의 비결정성만 제거하고 +이미 호환 검증된 action/monthly RNG의 후속 소비 위치는 바꾸지 않기 +위해서다. 테스트는 해당 경로에서 `Math.random()`을 호출하면 즉시 +실패한다. 인증 token, request/event correlation ID 같은 보안·운영 식별자의 +`crypto` RNG와 사용자가 랜덤 능력치 버튼으로 만드는 클라이언트 입력은 +이 게임-state 재현 계약과 구분한다. + +root ESLint 설정도 `app/game-engine/src`와 `packages/logic/src`에서 +`Math.random` property 사용을 오류로 처리하므로 새 authoritative 경로가 +같은 결함을 다시 도입할 수 없다. + ## Open Questions / Follow-ups - `Event\Engine` is a stub with a TODO; it is not currently used in the main diff --git a/eslint.config.js b/eslint.config.js index b904f3b..e248a20 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -98,6 +98,20 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', }, }, + { + files: ['app/game-engine/src/**/*.{ts,tsx}', 'packages/logic/src/**/*.{ts,tsx}'], + rules: { + 'no-restricted-properties': [ + 'error', + { + object: 'Math', + property: 'random', + message: + 'Authoritative game state must use an explicitly seeded RandUtil/LiteHashDRBG stream.', + }, + ], + }, + }, { files: ['**/*.js', '**/*.mjs', '**/*.cjs'], rules: { diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index 7ef2e92..01e8c31 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -1,3 +1,4 @@ +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { City, General, @@ -18,6 +19,7 @@ import type { WorldSeedPayload, WorldSnapshot, } from './types.js'; +import { simpleSerialize } from '../war/utils.js'; export interface ScenarioBootstrapOptions { includeNeutralNation?: boolean; @@ -144,16 +146,38 @@ const resolveKillturnFromDeathYear = ( currentYear: number | null, currentMonth: number, deathYear: number, + deathMonth: number, fallback: number ): number => { if (currentYear === null || !Number.isFinite(deathYear) || deathYear <= 0) { return fallback; } - const deathMonth = Math.floor(Math.random() * 12) + 1; const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth); return Math.max(diff, 0); }; +export const resolveScenarioGeneralDeathMonth = (input: { + scenarioTitle: string; + startYear: number | null; + contextLabel: string; + generalId: number; + generalName: string; + deathYear: number; +}): number => + new RandUtil( + new LiteHashDRBG( + simpleSerialize( + input.scenarioTitle, + input.startYear ?? 0, + input.contextLabel, + input.generalId, + input.generalName, + input.deathYear, + 'deathMonth' + ) + ) + ).nextRangeInt(1, 12); + const resolveAge = (startYear: number | null, birthYear: number): number => { if (startYear === null || birthYear <= 0) { return 20; @@ -309,6 +333,14 @@ const buildGeneralSeeds = ( const cityId = resolveCityId(row.city, cityByName, warnings, row.name); const birthYear = resolveBirthYear(row.birthYear, scenario.startYear); const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear); + const deathMonth = resolveScenarioGeneralDeathMonth({ + scenarioTitle: scenario.title, + startYear: scenario.startYear, + contextLabel, + generalId: id, + generalName: row.name, + deathYear, + }); const officerLevel = resolveOfficerLevel(row.officerLevel, nationId); const age = resolveAge(scenario.startYear, birthYear); const stats = { @@ -319,6 +351,7 @@ const buildGeneralSeeds = ( const seedMeta: Record = { source: contextLabel, + deathMonth, specage: buildSpecialityAge(retirementYear, age, 12), specage2: buildSpecialityAge(retirementYear, age, 6), }; @@ -379,7 +412,14 @@ const buildGeneralSeeds = ( seeds.push(seed); const generalMeta: GeneralMeta = { - killturn: resolveKillturnFromDeathYear(scenario.startYear, 1, deathYear, DEFAULT_GENERAL_KILLTURN), + killturn: resolveKillturnFromDeathYear( + scenario.startYear, + 1, + deathYear, + deathMonth, + DEFAULT_GENERAL_KILLTURN + ), + deathMonth, npcType, crewTypeId: defaultCrewTypeId, specage: buildSpecialityAge(retirementYear, age, 12), diff --git a/packages/logic/test/worldBootstrap.test.ts b/packages/logic/test/worldBootstrap.test.ts index e47362a..3379974 100644 --- a/packages/logic/test/worldBootstrap.test.ts +++ b/packages/logic/test/worldBootstrap.test.ts @@ -1,10 +1,20 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ScenarioDefinition } from '../src/scenario/types.js'; import type { MapDefinition, UnitSetDefinition } from '../src/world/types.js'; import { buildScenarioBootstrap } from '../src/world/bootstrap.js'; describe('scenario bootstrap', () => { + beforeEach(() => { + vi.spyOn(Math, 'random').mockImplementation(() => { + throw new Error('scenario bootstrap must not use Math.random'); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('builds snapshot and seed from scenario/map inputs', () => { const scenario: ScenarioDefinition = { title: 'Test Scenario', @@ -120,7 +130,14 @@ describe('scenario bootstrap', () => { expect(result.snapshot.generals[0]?.role.specialDomestic).toBe('Special'); expect(result.snapshot.generals[0]?.role.specialWar).toBeNull(); expect(result.snapshot.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 }); - expect(result.seed.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 }); + expect(result.seed.generals[0]?.meta).toMatchObject({ + deathMonth: expect.any(Number), + specage: 25, + specage2: 30, + }); + expect(buildScenarioBootstrap({ scenario, map, unitSet }).snapshot.generals[0]?.meta).toEqual( + result.snapshot.generals[0]?.meta + ); expect(result.seed.generals[0]?.npcType).toBe(2); expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario'); });