Files
core2026/packages/logic/src/world/bootstrap.ts
T

1045 lines
34 KiB
TypeScript

import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type {
City,
General,
GeneralMeta,
GeneralTriggerState,
Nation,
TriggerValue,
} from '@sammo-ts/logic/domain/entities.js';
import type { ScenarioDefinition, ScenarioGeneral } from '@sammo-ts/logic/scenario/types.js';
import {
isDomesticTraitKey,
isEventDomesticTraitKey,
isWarTraitKey,
} from '@sammo-ts/logic/actionModules/traits/index.js';
import type {
CitySeed,
GeneralSeed,
MapDefaults,
MapDefinition,
NationSeed,
ScenarioMeta,
UnitSetDefinition,
WorldSeedPayload,
WorldSnapshot,
} from './types.js';
import { simpleSerialize } from '../war/utils.js';
export interface ScenarioBootstrapOptions {
includeNeutralNation?: boolean;
includeNeutralNationInSeed?: boolean;
neutralNationName?: string;
neutralNationColor?: string;
defaultGeneralGold?: number;
defaultGeneralRice?: number;
defaultCrewTypeId?: number;
nationTypePrefix?: string;
mapDefaults?: Partial<MapDefaults>;
/**
* Legacy scenario installation uses the world's hidden seed while resolving
* generals whose city is omitted. Keep this input explicit so bootstrap
* remains deterministic in tests and reset operations.
*/
hiddenSeed?: string | number;
initialYear?: number;
initialMonth?: number;
turnTermMinutes?: number;
}
export type ScenarioBootstrapWarningCode =
| 'map_name_mismatch'
| 'unit_set_mismatch'
| 'duplicate_city_name'
| 'unknown_city'
| 'duplicate_city_owner'
| 'unknown_general_city'
| 'unknown_general_nation';
export interface ScenarioBootstrapWarning {
code: ScenarioBootstrapWarningCode;
message: string;
}
export interface ScenarioBootstrapInput {
scenario: ScenarioDefinition;
map: MapDefinition;
unitSet?: UnitSetDefinition;
options?: ScenarioBootstrapOptions;
}
export interface ScenarioBootstrapResult {
snapshot: WorldSnapshot;
seed: WorldSeedPayload;
warnings: ScenarioBootstrapWarning[];
}
const DEFAULT_NEUTRAL_NATION_NAME = '재야';
const DEFAULT_NEUTRAL_NATION_COLOR = '#000000';
const DEFAULT_GENERAL_GOLD = 1000;
const DEFAULT_GENERAL_RICE = 1000;
const DEFAULT_CREWTYPE_ID = 1100;
const DEFAULT_GENERAL_KILLTURN = 24;
const DEFAULT_AVAILABLE_PERSONALITIES = [
'che_안전',
'che_유지',
'che_재간',
'che_출세',
'che_할거',
'che_정복',
'che_패권',
'che_의협',
'che_대의',
'che_왕좌',
];
const DEFAULT_CITY_TRUST = 50;
const DEFAULT_CITY_TRADE = 100;
const DEFAULT_CITY_SUPPLY_STATE = 1;
const DEFAULT_CITY_FRONT_STATE = 0;
// Ref Scenario\GeneralBuilder::build() decorates the persisted name after
// setNPCType(); scenario JSON keeps the undecorated source name.
const LEGACY_GENERAL_NAME_PREFIX_BY_NPC_TYPE: Readonly<Record<number, string>> = {
0: '',
1: 'ⓝ',
2: 'ⓝ',
3: 'ⓜ',
4: 'ⓖ',
5: '㉥',
6: 'ⓤ',
9: 'ⓞ',
};
const decorateLegacyGeneralName = (name: string, npcType: number): string =>
`${LEGACY_GENERAL_NAME_PREFIX_BY_NPC_TYPE[npcType] ?? 'ⓧ'}${name}`;
const canonicalizeDomesticTrait = (raw: string | null | undefined): string | null => {
if (!raw || raw === 'None') {
return null;
}
if (isDomesticTraitKey(raw) || isEventDomesticTraitKey(raw)) {
return raw;
}
const domesticKey = `che_${raw}`;
if (isDomesticTraitKey(domesticKey)) {
return domesticKey;
}
const eventKey = `che_event_${raw}`;
if (isEventDomesticTraitKey(eventKey)) {
return eventKey;
}
return null;
};
const canonicalizeWarTrait = (raw: string | null | undefined): string | null => {
if (!raw || raw === 'None') {
return null;
}
if (isWarTraitKey(raw)) {
return raw;
}
const warKey = `che_${raw}`;
return isWarTraitKey(warKey) ? warKey : null;
};
// Scenario rows expose one legacy speciality column. GeneralBuilder tries the
// war catalogue first, then the domestic catalogue, and persists the resolved
// class code. An explicit war slot keeps the single speciality in the domestic
// slot. Preserve unknown values in the domestic slot so custom scenario packs
// retain their prior data even when their module is not installed here.
const resolveScenarioTraits = (
special: string | null,
explicitWar: string | null | undefined
): { specialDomestic: string | null; specialWar: string | null } => {
const domestic = canonicalizeDomesticTrait(special);
const inferredWar = canonicalizeWarTrait(special);
const retainedDomestic = special && special !== 'None' ? special : null;
const retainedWar = explicitWar && explicitWar !== 'None' ? explicitWar : null;
const resolvedExplicitWar = canonicalizeWarTrait(explicitWar) ?? retainedWar;
if (resolvedExplicitWar !== null) {
return {
specialDomestic: domestic ?? retainedDomestic,
specialWar: resolvedExplicitWar,
};
}
if (inferredWar !== null) {
return {
specialDomestic: null,
specialWar: inferredWar,
};
}
return {
specialDomestic: domestic ?? retainedDomestic,
specialWar: null,
};
};
const createScenarioMeta = (scenario: ScenarioDefinition): ScenarioMeta => ({
title: scenario.title,
startYear: scenario.startYear,
life: scenario.life,
fiction: scenario.fiction,
history: scenario.history,
ignoreDefaultEvents: scenario.ignoreDefaultEvents,
});
const createEmptyTriggerState = (): GeneralTriggerState => ({
flags: {},
counters: {},
modifiers: {},
meta: {},
});
// GameConstBase::$defaultInitialEvents / $defaultEvents. Ref prepends these
// rows to every scenario unless ignoreDefaultEvents is enabled; scenario JSON
// only contains its additional rows.
const LEGACY_DEFAULT_INITIAL_EVENTS: unknown[] = [
[true, ['NoticeToHistoryLog', '<S>2년간 거병 및 건국이 가능합니다.</>', 6]],
];
const LEGACY_DEFAULT_EVENTS: unknown[] = [
['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']],
[
'month',
9_000,
['Date', '==', null, 1],
['MergeInheritPointRank'],
['ProcessSemiAnnual', 'gold'],
['ProcessIncome', 'gold'],
['ResetOfficerLock'],
['RaiseDisaster'],
['RandomizeCityTradeRate'],
['NewYear'],
['AssignGeneralSpeciality'],
],
['month', 9_000, ['Date', '==', null, 4], ['ResetOfficerLock'], ['RaiseDisaster']],
[
'month',
9_000,
['Date', '==', null, 7],
['MergeInheritPointRank'],
['ProcessSemiAnnual', 'rice'],
['ProcessIncome', 'rice'],
['ResetOfficerLock'],
['RaiseDisaster'],
['RandomizeCityTradeRate'],
],
['month', 9_000, ['Date', '==', null, 10], ['ResetOfficerLock'], ['RaiseDisaster']],
[
'month',
2_000,
['DateRelative', '==', 1, 1],
['NoticeToHistoryLog', '<S>2년 뒤 출병 제한이 풀립니다.</>', 6],
['DeleteEvent'],
],
[
'month',
2_000,
['DateRelative', '==', 2, 1],
['NoticeToHistoryLog', '<S>1년 뒤 출병 제한이 풀립니다.</>', 6],
['DeleteEvent'],
],
[
'month',
2_000,
['DateRelative', '==', 2, 7],
['NoticeToHistoryLog', '<S>6개월 뒤 출병 제한이 풀립니다. 병력을 준비해주세요.</>', 6],
['DeleteEvent'],
],
[
'month',
2_000,
['DateRelative', '==', 3, 1],
['NoticeToHistoryLog', '<S>출병 제한이 풀렸습니다.</>', 6],
['DeleteEvent'],
],
[
'month',
2_000,
['DateRelative', '==', 4, 1],
['NoticeToHistoryLog', '<S>이제부터 하야, 망명시 패널티가 적용됩니다.</>', 6],
['AddGlobalBetray', 1, 0],
['AddGlobalBetray', 1, 1],
['DeleteEvent'],
],
['month', 1_000, true, ['UpdateNationLevel'], ['ProvideNPCTroopLeader']],
['united', 5_000, true, ['MergeInheritPointRank']],
];
const addTriggerMeta = (
meta: Record<string, TriggerValue>,
key: string,
value: TriggerValue | null | undefined
): void => {
if (value === null || value === undefined) {
return;
}
meta[key] = value;
};
const resolveMapDefaults = (map: MapDefinition, options?: ScenarioBootstrapOptions): MapDefaults => {
const override = options?.mapDefaults ?? {};
return {
trust: override.trust ?? map.defaults?.trust ?? DEFAULT_CITY_TRUST,
trade: override.trade ?? map.defaults?.trade ?? DEFAULT_CITY_TRADE,
supplyState: override.supplyState ?? map.defaults?.supplyState ?? DEFAULT_CITY_SUPPLY_STATE,
frontState: override.frontState ?? map.defaults?.frontState ?? DEFAULT_CITY_FRONT_STATE,
};
};
const resolveNationType = (rawType: string, prefix: string): string => {
const trimmed = rawType.trim();
if (!trimmed) {
return `${prefix}중립`;
}
if (trimmed.includes('_')) {
return trimmed;
}
return `${prefix}${trimmed}`;
};
const resolveBirthYear = (birthYear: number, startYear: number | null): number => {
if (birthYear > 0) {
return birthYear;
}
if (startYear !== null) {
return startYear - 20;
}
return 0;
};
const resolveDeathYear = (deathYear: number, birthYear: number, startYear: number | null): number => {
if (deathYear > 0) {
return deathYear;
}
if (birthYear > 0) {
return birthYear + 60;
}
if (startYear !== null) {
return startYear + 60;
}
return 0;
};
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 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;
}
return Math.max(startYear - birthYear, 0);
};
const buildSpecialityAge = (retirementYear: number, age: number, divisor: number): number =>
Math.max(Math.round((retirementYear - age) / divisor), 3) + age;
const resolveBootstrapSpecialityAge = (
scenarioStartYear: number | null,
birthYear: number,
retirementYear: number,
divisor: number
): number => buildSpecialityAge(retirementYear, resolveAge(scenarioStartYear, birthYear), divisor);
const ADULT_GENERAL_AGE = 14;
type GeneralBootstrapDisposition = 'active' | 'delayed' | 'expired';
const resolveGeneralBootstrapDisposition = (
general: ScenarioGeneral,
startYear: number | null
): GeneralBootstrapDisposition => {
if (startYear === null) {
return 'active';
}
if (general.deathYear <= startYear) {
return 'expired';
}
if (startYear - general.birthYear < ADULT_GENERAL_AGE) {
return 'delayed';
}
return 'active';
};
const buildDelayedGeneralAction = (general: ScenarioGeneral, nationId: number, npcType: 2 | 6): unknown[] => {
const common = [
general.affinity ?? 0,
general.name,
general.picture,
nationId,
general.city,
general.leadership,
general.strength,
general.intelligence,
];
if (npcType === 2) {
return [
'RegNPC',
...common,
general.officerLevel,
general.birthYear,
general.deathYear,
general.personality,
general.special,
general.text ?? '',
];
}
return [
'RegNeutralNPC',
...common,
general.birthYear,
general.deathYear,
general.personality,
general.special,
general.text ?? '',
];
};
const resolveOfficerLevel = (officerLevel: number, nationId: number): number => {
if (officerLevel > 0) {
return officerLevel;
}
if (nationId > 0) {
return 1;
}
return 0;
};
const resolveNationId = (
rawNation: number | string | null,
nationNameToId: Map<string, number>,
warnings: ScenarioBootstrapWarning[],
generalName: string
): number => {
if (rawNation === null) {
return 0;
}
if (typeof rawNation === 'number') {
return rawNation;
}
const nationId = nationNameToId.get(rawNation);
if (nationId === undefined) {
warnings.push({
code: 'unknown_general_nation',
message: `General ${generalName} references unknown nation ${rawNation}.`,
});
return 0;
}
return nationId;
};
const resolveCityId = (
rawCity: string | null,
cityByName: Map<string, { id: number }>,
warnings: ScenarioBootstrapWarning[],
generalName: string
): number => {
if (rawCity === null) {
return 0;
}
const city = cityByName.get(rawCity);
if (!city) {
warnings.push({
code: 'unknown_general_city',
message: `General ${generalName} references unknown city ${rawCity}.`,
});
return 0;
}
return city.id;
};
const buildGeneralSeeds = (
rows: ScenarioGeneral[],
npcType: number,
startId: number,
contextLabel: string,
scenario: ScenarioDefinition,
cityByName: Map<string, { id: number }>,
nationNameToId: Map<string, number>,
warnings: ScenarioBootstrapWarning[],
defaultCrewTypeId: number,
mapCities: MapDefinition['cities'],
nationCityIds: Map<number, number[]>,
installRng: RandUtil,
initializedValues: Map<ScenarioGeneral, { affinity: number; personality: string }>,
options?: ScenarioBootstrapOptions
): {
seeds: GeneralSeed[];
generals: General[];
nextId: number;
} => {
const seeds: GeneralSeed[] = [];
const generals: General[] = [];
let nextId = startId;
const defaultGold = options?.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD;
const defaultRice = options?.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
const rawRetirementYear = scenario.config.const.retirementYear;
const retirementYear =
typeof rawRetirementYear === 'number' && Number.isFinite(rawRetirementYear) ? rawRetirementYear : 80;
for (const row of rows) {
const id = nextId;
nextId += 1;
const name = decorateLegacyGeneralName(row.name, npcType);
const nationId = resolveNationId(row.nation, nationNameToId, warnings, row.name);
let cityId = resolveCityId(row.city, cityByName, warnings, row.name);
if (row.city === null) {
const ownedCityIds = nationId > 0 ? (nationCityIds.get(nationId) ?? []) : [];
const candidateCityIds = ownedCityIds.length > 0 ? ownedCityIds : mapCities.map((city) => city.id);
if (candidateCityIds.length > 0) {
cityId = installRng.choice(candidateCityIds);
}
}
const birthYear = resolveBirthYear(row.birthYear, scenario.startYear);
const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear);
const turnTermMinutes = Math.max(1, Math.floor(options?.turnTermMinutes ?? 60));
const initialTurnOffsetMicros =
installRng.nextRangeInt(0, turnTermMinutes * 60 - 1) * 1_000_000 + installRng.nextRangeInt(0, 999_999);
const deathMonth = installRng.nextRangeInt(1, 12);
const officerLevel = resolveOfficerLevel(row.officerLevel, nationId);
const initialYear = options?.initialYear ?? scenario.startYear;
const initialMonth = options?.initialMonth ?? 1;
const age = resolveAge(initialYear, birthYear);
const initialized = initializedValues.get(row);
if (!initialized) {
throw new Error(`Missing legacy initialization values for general ${row.name}.`);
}
const stats = {
leadership: row.leadership,
strength: row.strength,
intelligence: row.intelligence,
};
const { specialDomestic, specialWar } = resolveScenarioTraits(row.special, row.specialWar);
const seedMeta: Record<string, unknown> = {
source: contextLabel,
deathMonth,
initialTurnOffsetMicros,
explevel: 0,
dedlevel: 1,
// Ref's GeneralBuilder derives speciality ages from the scenario
// opening year even when installation stores a pre-opening age.
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6),
};
seedMeta.affinity = initialized.affinity;
seedMeta.personality = initialized.personality;
if (specialDomestic !== null) {
seedMeta.special = specialDomestic;
}
if (row.picture !== null) {
seedMeta.picture = row.picture;
}
if (specialWar !== null) {
seedMeta.specialWar = specialWar;
}
if (row.horse !== null && row.horse !== undefined) {
seedMeta.horse = row.horse;
}
if (row.weapon !== null && row.weapon !== undefined) {
seedMeta.weapon = row.weapon;
}
if (row.book !== null && row.book !== undefined) {
seedMeta.book = row.book;
}
if (row.item !== null && row.item !== undefined) {
seedMeta.item = row.item;
}
if (row.text !== null) {
seedMeta.text = row.text;
}
const seed: GeneralSeed = {
id,
name,
nationId,
cityId,
stats,
officerLevel,
birthYear,
deathYear,
affinity: initialized.affinity,
personality: initialized.personality,
special: specialDomestic,
specialWar,
horse: row.horse ?? null,
weapon: row.weapon ?? null,
book: row.book ?? null,
item: row.item ?? null,
picture: row.picture,
npcType,
text: row.text,
crewTypeId: defaultCrewTypeId,
experience: age * 100,
dedication: age * 100,
meta: seedMeta,
};
seeds.push(seed);
const generalMeta: GeneralMeta = {
killturn: resolveKillturnFromDeathYear(
initialYear,
initialMonth,
deathYear,
deathMonth,
DEFAULT_GENERAL_KILLTURN
),
deathMonth,
npcType,
explevel: 0,
dedlevel: 1,
crewTypeId: defaultCrewTypeId,
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6),
};
addTriggerMeta(generalMeta, 'affinity', initialized.affinity);
addTriggerMeta(generalMeta, 'personality', initialized.personality);
addTriggerMeta(generalMeta, 'special', specialDomestic ?? undefined);
addTriggerMeta(generalMeta, 'picture', row.picture ?? undefined);
addTriggerMeta(generalMeta, 'specialWar', specialWar ?? undefined);
addTriggerMeta(generalMeta, 'horse', row.horse ?? undefined);
addTriggerMeta(generalMeta, 'weapon', row.weapon ?? undefined);
addTriggerMeta(generalMeta, 'book', row.book ?? undefined);
addTriggerMeta(generalMeta, 'item', row.item ?? undefined);
addTriggerMeta(generalMeta, 'birthYear', birthYear);
addTriggerMeta(generalMeta, 'text', row.text ?? undefined);
generals.push({
id,
name,
nationId,
cityId,
troopId: 0,
stats,
experience: age * 100,
dedication: age * 100,
officerLevel,
role: {
personality: initialized.personality,
specialDomestic,
specialWar,
items: {
horse: row.horse ?? null,
weapon: row.weapon ?? null,
book: row.book ?? null,
item: row.item ?? null,
},
},
injury: 0,
gold: defaultGold,
rice: defaultRice,
crew: 0,
crewTypeId: defaultCrewTypeId,
train: 0,
atmos: 0,
age,
npcState: npcType,
triggerState: createEmptyTriggerState(),
meta: generalMeta,
});
}
return { seeds, generals, nextId };
};
// 시나리오, 맵, 유닛셋을 묶어 초기 월드 스냅샷과 시드 데이터를 만든다.
export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioBootstrapResult => {
const { scenario, map, unitSet, options } = input;
const warnings: ScenarioBootstrapWarning[] = [];
const environment = scenario.config.environment;
if (map.id !== environment.mapName && map.name !== environment.mapName) {
warnings.push({
code: 'map_name_mismatch',
message: `Scenario mapName ${environment.mapName} does not match map definition ${map.id}.`,
});
}
if (unitSet && unitSet.id !== environment.unitSet && unitSet.name !== environment.unitSet) {
warnings.push({
code: 'unit_set_mismatch',
message: `Scenario unitSet ${environment.unitSet} does not match unit set ${unitSet.id}.`,
});
}
const cityByName = new Map<string, { id: number }>();
for (const city of map.cities) {
if (cityByName.has(city.name)) {
warnings.push({
code: 'duplicate_city_name',
message: `Duplicate city name detected: ${city.name}.`,
});
continue;
}
cityByName.set(city.name, city);
}
const nationNameToId = new Map<string, number>();
for (const nation of scenario.nations) {
nationNameToId.set(nation.name, nation.id);
}
const cityOwnership = new Map<number, number>();
const nationCityIds = new Map<number, number[]>();
for (const nation of scenario.nations) {
const cityIds: number[] = [];
for (const cityName of nation.cities) {
const city = cityByName.get(cityName);
if (!city) {
warnings.push({
code: 'unknown_city',
message: `Nation ${nation.name} references unknown city ${cityName}.`,
});
continue;
}
const existing = cityOwnership.get(city.id);
if (existing !== undefined && existing !== nation.id) {
warnings.push({
code: 'duplicate_city_owner',
message: `City ${cityName} is already assigned to nation ${existing}.`,
});
}
cityOwnership.set(city.id, nation.id);
cityIds.push(city.id);
}
nationCityIds.set(nation.id, cityIds);
}
const scenarioMeta = createScenarioMeta(scenario);
const typePrefix = options?.nationTypePrefix ?? `${environment.mapName}_`;
const seedNations: NationSeed[] = [];
const domainNations: Nation[] = [];
const includeNeutralNation = options?.includeNeutralNation ?? true;
const includeNeutralNationInSeed = options?.includeNeutralNationInSeed ?? false;
if (includeNeutralNation) {
const neutralNation: Nation = {
id: 0,
name: options?.neutralNationName ?? DEFAULT_NEUTRAL_NATION_NAME,
color: options?.neutralNationColor ?? DEFAULT_NEUTRAL_NATION_COLOR,
capitalCityId: null,
chiefGeneralId: null,
gold: 0,
rice: 0,
power: 0,
level: 0,
typeCode: `${typePrefix}중립`,
meta: {},
};
domainNations.push(neutralNation);
if (includeNeutralNationInSeed) {
seedNations.push({
id: neutralNation.id,
name: neutralNation.name,
color: neutralNation.color,
gold: neutralNation.gold,
rice: neutralNation.rice,
infoText: null,
tech: 0,
typeCode: neutralNation.typeCode,
level: neutralNation.level,
cityIds: [],
capitalCityId: null,
meta: {},
});
}
}
for (const nation of scenario.nations) {
const cityIds = nationCityIds.get(nation.id) ?? [];
const capitalCityId = cityIds[0] ?? null;
const typeCode = resolveNationType(nation.type, typePrefix);
seedNations.push({
id: nation.id,
name: nation.name,
color: nation.color,
gold: nation.gold,
rice: nation.rice,
infoText: nation.infoText,
tech: nation.tech,
typeCode,
level: nation.level,
cityIds,
capitalCityId,
meta: {},
});
const nationMeta: Record<string, TriggerValue> = {
tech: nation.tech,
};
addTriggerMeta(nationMeta, 'infoText', nation.infoText ?? undefined);
domainNations.push({
id: nation.id,
name: nation.name,
color: nation.color,
capitalCityId,
chiefGeneralId: null,
gold: nation.gold,
rice: nation.rice,
power: 0,
level: nation.level,
typeCode,
meta: nationMeta,
});
}
const mapDefaults = resolveMapDefaults(map, options);
const installRng = new RandUtil(
new LiteHashDRBG(simpleSerialize(options?.hiddenSeed ?? scenario.title, 'InitScenario'))
);
const defaultCrewTypeId = unitSet?.defaultCrewTypeId ?? options?.defaultCrewTypeId ?? DEFAULT_CREWTYPE_ID;
const seedCities: CitySeed[] = [];
const domainCities: City[] = [];
for (const city of map.cities) {
const nationId = cityOwnership.get(city.id) ?? 0;
const rawCityMeta = city.meta ?? {};
const state =
typeof rawCityMeta.state === 'number' && Number.isFinite(rawCityMeta.state)
? Math.floor(rawCityMeta.state)
: 0;
const seed: CitySeed = {
id: city.id,
name: city.name,
nationId,
level: city.level,
state,
population: city.initial.population,
populationMax: city.max.population,
agriculture: city.initial.agriculture,
agricultureMax: city.max.agriculture,
commerce: city.initial.commerce,
commerceMax: city.max.commerce,
security: city.initial.security,
securityMax: city.max.security,
defence: city.initial.defence,
defenceMax: city.max.defence,
wall: city.initial.wall,
wallMax: city.max.wall,
supplyState: mapDefaults.supplyState,
frontState: mapDefaults.frontState,
trust: mapDefaults.trust,
trade: mapDefaults.trade,
region: city.region,
position: city.position,
connections: city.connections,
meta: {
...rawCityMeta,
state,
},
};
seedCities.push(seed);
const cityTriggerMeta: Record<string, TriggerValue> = {
region: city.region,
trust: seed.trust,
trade: seed.trade,
positionX: city.position.x,
positionY: city.position.y,
};
domainCities.push({
id: seed.id,
name: seed.name,
nationId: seed.nationId,
level: seed.level,
state,
population: seed.population,
populationMax: seed.populationMax,
agriculture: seed.agriculture,
agricultureMax: seed.agricultureMax,
commerce: seed.commerce,
commerceMax: seed.commerceMax,
security: seed.security,
securityMax: seed.securityMax,
supplyState: seed.supplyState,
frontState: seed.frontState,
defence: seed.defence,
defenceMax: seed.defenceMax,
wall: seed.wall,
wallMax: seed.wallMax,
meta: cityTriggerMeta,
});
}
let nextGeneralId = 1;
const allGeneralSeeds: GeneralSeed[] = [];
const allGenerals: General[] = [];
const delayedActionsByBirthYear = new Map<number, unknown[][]>();
const initializedValues = new Map<ScenarioGeneral, { affinity: number; personality: string }>();
const rawAvailablePersonalities = scenario.config.const.availablePersonality;
const availablePersonalities = Array.isArray(rawAvailablePersonalities)
? rawAvailablePersonalities.filter((value): value is string => typeof value === 'string')
: DEFAULT_AVAILABLE_PERSONALITIES;
const personalityPool =
availablePersonalities.length > 0 ? availablePersonalities : DEFAULT_AVAILABLE_PERSONALITIES;
for (const row of [...scenario.generals, ...scenario.generalsEx, ...scenario.generalsNeutral]) {
const affinity = row.affinity !== null && row.affinity > 0 ? row.affinity : installRng.nextRangeInt(1, 150);
const rawPersonality = row.personality ?? installRng.choice(personalityPool);
const personality = rawPersonality.includes('_') ? rawPersonality : `che_${rawPersonality}`;
initializedValues.set(row, { affinity, personality });
}
const partitionGenerals = (rows: ScenarioGeneral[], npcType: 2 | 6): ScenarioGeneral[] => {
const active: ScenarioGeneral[] = [];
for (const general of rows) {
const disposition = resolveGeneralBootstrapDisposition(general, scenario.startYear);
if (disposition === 'active') {
active.push(general);
continue;
}
if (disposition === 'expired') {
continue;
}
const nationId = resolveNationId(general.nation, nationNameToId, warnings, general.name);
const actions = delayedActionsByBirthYear.get(general.birthYear) ?? [];
actions.push(buildDelayedGeneralAction(general, nationId, npcType));
delayedActionsByBirthYear.set(general.birthYear, actions);
}
return active;
};
const activeGenerals = partitionGenerals(scenario.generals, 2);
const activeGeneralsEx = partitionGenerals(scenario.generalsEx, 2);
const activeGeneralsNeutral = partitionGenerals(scenario.generalsNeutral, 6);
const generalResult = buildGeneralSeeds(
activeGenerals,
2,
nextGeneralId,
'general',
scenario,
cityByName,
nationNameToId,
warnings,
defaultCrewTypeId,
map.cities,
nationCityIds,
installRng,
initializedValues,
options
);
allGeneralSeeds.push(...generalResult.seeds);
allGenerals.push(...generalResult.generals);
nextGeneralId = generalResult.nextId;
const generalExResult = buildGeneralSeeds(
activeGeneralsEx,
2,
nextGeneralId,
'general_ex',
scenario,
cityByName,
nationNameToId,
warnings,
defaultCrewTypeId,
map.cities,
nationCityIds,
installRng,
initializedValues,
options
);
allGeneralSeeds.push(...generalExResult.seeds);
allGenerals.push(...generalExResult.generals);
nextGeneralId = generalExResult.nextId;
const generalNeutralResult = buildGeneralSeeds(
activeGeneralsNeutral,
6,
nextGeneralId,
'general_neutral',
scenario,
cityByName,
nationNameToId,
warnings,
defaultCrewTypeId,
map.cities,
nationCityIds,
installRng,
initializedValues,
options
);
allGeneralSeeds.push(...generalNeutralResult.seeds);
allGenerals.push(...generalNeutralResult.generals);
const delayedGeneralEvents = Array.from(delayedActionsByBirthYear.entries()).map(([birthYear, actions]) => [
'Month',
1_000,
['Date', '>=', birthYear + ADULT_GENERAL_AGE, 1],
...actions,
['DeleteEvent'],
]);
const defaultEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_EVENTS;
const defaultInitialEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_INITIAL_EVENTS;
const events = [...defaultEvents, ...scenario.events, ...delayedGeneralEvents];
const seed: WorldSeedPayload = {
scenarioConfig: scenario.config,
scenarioMeta,
map,
...(unitSet ? { unitSet } : {}),
nations: seedNations,
cities: seedCities,
generals: allGeneralSeeds,
troops: [],
diplomacy: scenario.diplomacy,
events,
initialEvents: [...defaultInitialEvents, ...scenario.initialEvents],
};
const snapshot: WorldSnapshot = {
scenarioConfig: scenario.config,
scenarioMeta,
map,
...(unitSet ? { unitSet } : {}),
nations: domainNations,
cities: domainCities,
generals: allGenerals,
troops: [],
diplomacy: scenario.diplomacy,
events,
initialEvents: [...defaultInitialEvents, ...scenario.initialEvents],
};
return { snapshot, seed, warnings };
};