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; /** * 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> = { 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', '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', '2년 뒤 출병 제한이 풀립니다.', 6], ['DeleteEvent'], ], [ 'month', 2_000, ['DateRelative', '==', 2, 1], ['NoticeToHistoryLog', '1년 뒤 출병 제한이 풀립니다.', 6], ['DeleteEvent'], ], [ 'month', 2_000, ['DateRelative', '==', 2, 7], ['NoticeToHistoryLog', '6개월 뒤 출병 제한이 풀립니다. 병력을 준비해주세요.', 6], ['DeleteEvent'], ], [ 'month', 2_000, ['DateRelative', '==', 3, 1], ['NoticeToHistoryLog', '출병 제한이 풀렸습니다.', 6], ['DeleteEvent'], ], [ 'month', 2_000, ['DateRelative', '==', 4, 1], ['NoticeToHistoryLog', '이제부터 하야, 망명시 패널티가 적용됩니다.', 6], ['AddGlobalBetray', 1, 0], ['AddGlobalBetray', 1, 1], ['DeleteEvent'], ], ['month', 1_000, true, ['UpdateNationLevel'], ['ProvideNPCTroopLeader']], ['united', 5_000, true, ['MergeInheritPointRank']], ]; const addTriggerMeta = ( meta: Record, 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, 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, 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, nationNameToId: Map, warnings: ScenarioBootstrapWarning[], defaultCrewTypeId: number, mapCities: MapDefinition['cities'], nationCityIds: Map, installRng: RandUtil, initializedValues: Map, 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 = { 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(); 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(); for (const nation of scenario.nations) { nationNameToId.set(nation.name, nation.id); } const cityOwnership = new Map(); const nationCityIds = new Map(); 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 = { 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 = { 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(); const initializedValues = new Map(); 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 }; };