fix deterministic authoritative RNG fallbacks

This commit is contained in:
2026-07-28 05:46:08 +00:00
parent 53ec5d543e
commit 43966ea1ef
11 changed files with 267 additions and 42 deletions
+25 -2
View File
@@ -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;
@@ -27,17 +27,6 @@ const resolveServerId = (world: InMemoryTurnWorld): string | null => {
return typeof value === 'string' && value !== '' ? value : null;
};
const shuffleLegacy = <T>(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,
@@ -111,17 +111,6 @@ const calculateAverageCity = (rng: RandUtil, cities: City[]): CityValues => {
) as CityValues;
};
const shuffleLegacy = <T>(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
@@ -57,14 +57,16 @@ const readPattern = (world: InMemoryTurnWorld, config: Record<string, unknown>):
);
};
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();