1045 lines
34 KiB
TypeScript
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 };
|
|
};
|