Skip to main content

rpfm_extensions/startpos/world/writer/
armies.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Writer of the forces of a faction (`FACTION/ARMY_ARRAY`): their units and legacies.
12
13use std::collections::HashMap;
14
15use rpfm_lib::error::Result;
16use rpfm_lib::files::esf::RecordNode;
17
18use super::characters::CharacterWriter;
19use super::forces::{ForcePlan, PlannedForce, PlannedUnit};
20use super::regions::{settlement_keys, settlement_name_key};
21use super::{child, horde_details, next_id, start_pos_faction_row, WorldInputs, HORDE_BUILDING_COLUMNS};
22use crate::startpos::world::calendar::Calendar;
23use crate::startpos::world::FactionEntry;
24use crate::startpos::world::factions::faction_summary;
25use crate::startpos::world::legacy::{legacies, legacy_skill_set, ForceKind, Legacy, LegacyForce};
26use crate::startpos::world::unit_bonuses::StatBonusRules;
27
28/// Path of a faction.
29const FACTION_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL/WORLD/FACTION_ARRAY/FACTION";
30
31/// Path of the forces of a faction.
32const ARMY_ARRAY_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL/WORLD/FACTION_ARRAY/FACTION/ARMY_ARRAY";
33
34/// Path of a force.
35const FORCE_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL/WORLD/FACTION_ARRAY/FACTION/ARMY_ARRAY/MILITARY_FORCE";
36
37/// Path of a unit.
38const UNIT_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL/WORLD/FACTION_ARRAY/FACTION/ARMY_ARRAY/MILITARY_FORCE/UNIT_CONTAINER/UNITS_ARRAY/UNIT";
39
40/// Path of a force's legacy.
41const LEGACY_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL/WORLD/FACTION_ARRAY/FACTION/ARMY_ARRAY/MILITARY_FORCE/MILITARY_FORCE_LEGACY";
42
43/// Path of a force's legacy history.
44const HISTORY_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL/WORLD/FACTION_ARRAY/FACTION/ARMY_ARRAY/MILITARY_FORCE/MILITARY_FORCE_LEGACY/MILITARY_FORCE_LEGACY_HISTORY";
45
46/// Path of a force's horde details.
47const HORDE_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL/WORLD/FACTION_ARRAY/FACTION/ARMY_ARRAY/MILITARY_FORCE/MILITARY_FORCE_HORDE_DETAILS";
48
49/// Path of a building slot of a horde.
50const HORDE_SLOT_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL/WORLD/FACTION_ARRAY/FACTION/ARMY_ARRAY/MILITARY_FORCE/MILITARY_FORCE_HORDE_DETAILS/MILITARY_FORCE_SLOT_MANAGER/SLOT_BLOCK/MILITARY_FORCE_SLOT";
51
52/// Slot type of a horde's primary building slot.
53const HORDE_PRIMARY_SLOT: &str = "horde_primary";
54
55/// Slot type of a horde's other building slots.
56const HORDE_SECONDARY_SLOT: &str = "horde_secondary";
57
58/// Value of the two floats of a horde building (`BUILDING_BASE^+3` and `^+4`, meaning unknown).
59const HORDE_BUILDING_VALUE: f32 = 1.0;
60
61/// Action point multiplier of the units of generals' forces. Garrison units keep their base action points.
62const GENERAL_ACTION_POINTS_MULTIPLIER: f32 = 1.1;
63
64//---------------------------------------------------------------------------//
65//                              Types
66//---------------------------------------------------------------------------//
67
68/// Writes the forces of the factions.
69pub struct ArmyWriter<'a, 'b> {
70
71    /// Tables, startpos rows and layouts.
72    inputs: &'b WorldInputs<'a>,
73
74    /// Characters and forces of the world.
75    forces: &'b ForcePlan<'a>,
76
77    /// Garrison residence id of each region's settlement, by region key.
78    residences: &'b HashMap<String, u32>,
79
80    /// Men and action points of every unit, by `main_units` key.
81    stats: HashMap<String, UnitStats>,
82
83    /// The campaign's start year.
84    start_year: u32,
85
86    /// Horde details of the horde armies, by force id.
87    hordes: HashMap<u32, HordePlan>,
88
89    /// Writer of the characters, for the commanders' positions and names.
90    characters: &'b CharacterWriter<'a, 'b>,
91
92    /// `start_pos_settlements.settlement_id` of every settlement, by row id.
93    settlement_keys: HashMap<String, String>,
94
95    /// Province of every region, by region key.
96    provinces: HashMap<String, String>,
97
98    /// Stat bonuses of units from buildings.
99    stat_bonuses: StatBonusRules,
100}
101
102/// The horde details of a horde army: ids of its recruitment manager and slots, its buildings and development.
103#[derive(Debug, Clone, Default)]
104struct HordePlan {
105
106    /// Recruitment manager id.
107    recruitment_id: u32,
108
109    /// Id of the first building slot (the others follow it).
110    first_slot_id: u32,
111
112    /// Building of each slot, with its id: the row's buildings in column order, packed into the first slots.
113    buildings: Vec<Option<(String, u32)>>,
114
115    /// Development points (`start_pos_horde_details.starting_dev_points` plus one).
116    development: u32,
117
118    /// Government type of the faction, for its buildings.
119    government: String,
120}
121
122/// Men and action points of a unit (`main_units` and its land or naval unit).
123#[derive(Debug, Clone, Copy, Default)]
124struct UnitStats {
125
126    /// Men of a full unit (`main_units.num_men`).
127    men: u32,
128
129    /// Base action points (`campaign_action_points` of the land or naval unit).
130    action_points: u32,
131}
132
133//---------------------------------------------------------------------------//
134//                              Implementations
135//---------------------------------------------------------------------------//
136
137impl<'a, 'b> ArmyWriter<'a, 'b> {
138
139    /// Prepares the writer of the forces.
140    ///
141    /// # Arguments
142    ///
143    /// * `inputs` - Tables, startpos rows and layouts.
144    /// * `forces` - Characters and forces of the world.
145    /// * `characters` - Writer of the characters.
146    /// * `residences` - Garrison residence id of each region's settlement, by region key.
147    /// * `late_recruitment` - First recruitment manager id free after the provinces and seas, for horde armies
148    ///   without horde details.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if a table or column is missing.
153    pub fn new(inputs: &'b WorldInputs<'a>, forces: &'b ForcePlan<'a>, characters: &'b CharacterWriter<'a, 'b>, residences: &'b HashMap<String, u32>, late_recruitment: u32) -> Result<Self> {
154        let mut provinces = HashMap::new();
155        for row in inputs.tables.table("region_to_province_junctions")?.rows() {
156            provinces.insert(row.str("region")?.into_owned(), row.str("province")?.into_owned());
157        }
158
159        Ok(Self {
160            hordes: horde_plans(inputs, forces, late_recruitment)?,
161            settlement_keys: settlement_keys(inputs)?,
162            provinces,
163            characters,
164            stats: unit_stats(inputs)?,
165            stat_bonuses: StatBonusRules::new(inputs.tables)?,
166            start_year: start_year(inputs)?,
167            inputs,
168            forces,
169            residences,
170        })
171    }
172
173    /// Writes the forces of a faction.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if a table, column or layout is missing.
178    pub fn write_army_array(&self, faction: &FactionEntry) -> Result<RecordNode> {
179        let faction_forces = self.forces.forces_of(faction.key()).collect::<Vec<_>>();
180        let force_legacies = self.legacies(faction)?;
181
182        let mut array = self.inputs.layout.builder(ARMY_ARRAY_PATH)?;
183        for (force, legacy) in faction_forces.iter().zip(&force_legacies) {
184            array.push_group()?.set_record("MILITARY_FORCE", Some(self.write_force(force, legacy)?))?;
185        }
186        array.build()
187    }
188
189    /// Returns the legacies of a faction's forces, in startpos order.
190    fn legacies(&self, faction: &FactionEntry) -> Result<Vec<Legacy>> {
191        let legacy_forces = self.forces.forces_of(faction.key()).map(|force| LegacyForce {
192            kind: force.kind(),
193            general: self.forces.characters()[force.commander()].entry().map(|entry| entry.id().to_owned()),
194        }).collect::<Vec<_>>();
195        legacies(self.inputs.tables, self.inputs.index, faction, &legacy_forces, self.inputs.legacy_overrides)
196    }
197
198    /// Writes the legacy names and emblems a faction has used (`MILITARY_FORCE_LEGACY_NAMES_AND_EMBLEMS`): the drawn
199    /// names and the emblems of its armies and navies, and whether it uses override names. The rest of the record is
200    /// the game's RNG state.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if a table, column or layout is missing.
205    pub fn write_used_legacies(&self, faction: &FactionEntry) -> Result<RecordNode> {
206        let mut used = [vec![], vec![], vec![], vec![]];
207        let mut overrides = false;
208        for (force, legacy) in self.forces.forces_of(faction.key()).zip(self.legacies(faction)?) {
209            let navy = usize::from(force.kind() == ForceKind::Navy);
210            match force.kind() {
211                ForceKind::Army | ForceKind::Navy if legacy.override_name().is_empty() => used[navy].push(legacy.name().to_owned()),
212                ForceKind::Army | ForceKind::Navy => overrides = true,
213                ForceKind::GarrisonArmy | ForceKind::GarrisonNavy => continue,
214            }
215            used[2 + navy].push(legacy.emblem().to_owned());
216        }
217
218        let [army_names, navy_names, army_emblems, navy_emblems] = used;
219        let mut record = child(self.inputs.layout, FACTION_PATH, "MILITARY_FORCE_LEGACY_NAMES_AND_EMBLEMS")?;
220        record.group()?
221            .set_strings("^+3", army_names)?
222            .set_strings("^+6", navy_names)?
223            .set_strings("^+9", army_emblems)?
224            .set_strings("^+12", navy_emblems)?
225            .set_bool("^+13", overrides)?;
226        record.build()
227    }
228
229    /// Writes a force: ids, legacy, units, the residences it belongs to and its kind.
230    fn write_force(&self, force: &PlannedForce, legacy: &Legacy) -> Result<RecordNode> {
231        let layout = self.inputs.layout;
232        let residence = |region: Option<&String>| region.and_then(|region| self.residences.get(region)).copied().unwrap_or_default();
233        let garrison_residence = |kind| residence(force.region().as_ref().filter(|_| force.kind() == kind));
234        let commander_id = self.forces.characters()[force.commander()].id();
235
236        let mut units = child(layout, &format!("{FORCE_PATH}/UNIT_CONTAINER"), "UNITS_ARRAY")?;
237        for (unit, id) in force.units().iter().zip(force.unit_ids()) {
238            let commander = if unit.commands() { commander_id } else { 0 };
239            units.push_group()?.set_record("UNIT", Some(self.write_unit(force, unit, id, commander)?))?;
240        }
241        let mut container = child(layout, FORCE_PATH, "UNIT_CONTAINER")?;
242        container.group()?.set_record("UNITS_ARRAY", Some(units.build()?))?;
243
244        let mut record = layout.builder(FORCE_PATH)?;
245        record.group()?
246            .set_u32("^+1", force.id())?
247            .set_u32("^+2", commander_id)?
248            .set_record("MILITARY_FORCE_LEGACY", Some(self.write_legacy(force, legacy)?))?
249            .set_record("UNIT_CONTAINER", Some(container.build()?))?
250            .set_u32("UNIT_CONTAINER+1", residence(force.stationed_in().as_ref()))?
251            .set_u32("UNIT_CONTAINER+11", garrison_residence(ForceKind::GarrisonArmy))?
252            .set_u32("UNIT_CONTAINER+12", garrison_residence(ForceKind::GarrisonNavy))?
253            .set_bool("UNIT_CONTAINER+13", is_navy(force))?;
254        if let Some(horde) = self.hordes.get(&force.id()) {
255            record.group()?
256                .set_bool("MILITARY_FORCE_MORALE+1", true)?
257                .set_record("MILITARY_FORCE_HORDE_DETAILS", Some(self.write_horde_details(force, horde)?))?;
258        }
259        record.build()
260    }
261
262    /// Writes the horde details of a horde army: its recruitment manager, building slots and development.
263    fn write_horde_details(&self, force: &PlannedForce, horde: &HordePlan) -> Result<RecordNode> {
264        let layout = self.inputs.layout;
265        let mut recruitment = child(layout, HORDE_PATH, "RECRUITMENT_MANAGER")?;
266        recruitment.group()?.set_u32("^+1", horde.recruitment_id)?;
267
268        let mut slots = child(layout, &format!("{HORDE_PATH}/MILITARY_FORCE_SLOT_MANAGER"), "SLOT_BLOCK")?;
269        for (index, building) in horde.buildings.iter().enumerate() {
270            let primary = index == 0;
271            let base = building.as_ref().map(|(key, id)| {
272                let mut base = child(layout, &format!("{HORDE_SLOT_PATH}/MILITARY_FORCE_BUILDING_MANAGER"), "BUILDING_BASE")?;
273                base.group()?
274                    .set_u32("^+1", *id)?
275                    .set_str("^+2", key)?
276                    .set_f32("^+3", HORDE_BUILDING_VALUE)?
277                    .set_f32("^+4", HORDE_BUILDING_VALUE)?
278                    .set_str("^+5", force.faction().key())?
279                    .set_str("^+6", &horde.government)?;
280                base.build()
281            }).transpose()?;
282
283            let mut manager = child(layout, HORDE_SLOT_PATH, "MILITARY_FORCE_BUILDING_MANAGER")?;
284            manager.group()?
285                .set_bool("^+2", base.is_some())?
286                .set_record("BUILDING_BASE", base)?;
287
288            let slot_type = if primary { HORDE_PRIMARY_SLOT } else { HORDE_SECONDARY_SLOT };
289            let mut slot = layout.builder(HORDE_SLOT_PATH)?;
290            slot.group()?
291                .set_u32("^+1", horde.first_slot_id + u32::try_from(index).unwrap_or_default())?
292                .set_u32("^+2", u32::try_from(index).unwrap_or_default())?
293                .set_record("MILITARY_FORCE_BUILDING_MANAGER", Some(manager.build()?))?
294                .set_str("MILITARY_FORCE_BUILDING_MANAGER+1", slot_type)?
295                .set_str("MILITARY_FORCE_BUILDING_MANAGER+2", slot_type)?
296                .set_bool("MILITARY_FORCE_BUILDING_MANAGER+4", primary)?;
297            slots.push_group()?.set_record("MILITARY_FORCE_SLOT", Some(slot.build()?))?;
298        }
299        let mut slot_manager = child(layout, HORDE_PATH, "MILITARY_FORCE_SLOT_MANAGER")?;
300        slot_manager.group()?.set_record("SLOT_BLOCK", Some(slots.build()?))?;
301
302        let mut development = child(layout, HORDE_PATH, "MILITARY_FORCE_DEVELOPMENT")?;
303        development.group()?.set_u32("^+1", horde.development)?;
304
305        let mut record = layout.builder(HORDE_PATH)?;
306        record.group()?
307            .set_record("RECRUITMENT_MANAGER", Some(recruitment.build()?))?
308            .set_record("MILITARY_FORCE_SLOT_MANAGER", Some(slot_manager.build()?))?
309            .set_record("MILITARY_FORCE_DEVELOPMENT", Some(development.build()?))?;
310        record.build()
311    }
312
313    /// Writes the legacy of a force: its skill set, name, kind, emblem and number.
314    fn write_legacy(&self, force: &PlannedForce, legacy: &Legacy) -> Result<RecordNode> {
315        let layout = self.inputs.layout;
316        let mut skills = child(layout, LEGACY_PATH, "CAMPAIGN_SKILLS")?;
317        skills.group()?.set_str("^+2", &legacy_skill_set(force.faction(), is_navy(force)))?;
318
319        let mut name = child(layout, LEGACY_PATH, "CAMPAIGN_LOCALISATION")?;
320        name.group()?.set_str("^+1", legacy.name())?;
321
322        let mut record = layout.builder(LEGACY_PATH)?;
323        record.group()?
324            .set_record("MILITARY_FORCE_LEGACY_HISTORY", Some(self.write_history(force)?))?
325            .set_u32("^+1", force.id())?
326            .set_record("CAMPAIGN_SKILLS", Some(skills.build()?))?
327            .set_record("CAMPAIGN_LOCALISATION", Some(name.build()?))?
328            .set_str("CAMPAIGN_LOCALISATION+1", legacy.override_name())?
329            .set_u32("CAMPAIGN_LOCALISATION+2", force.kind() as u32)?
330            .set_str("CAMPAIGN_LOCALISATION+3", legacy.emblem())?
331            .set_u32("CAMPAIGN_LOCALISATION+4", legacy.number())?;
332        record.build()
333    }
334
335    /// Writes where and when a force was raised (`MILITARY_FORCE_LEGACY_HISTORY`): the campaign's start (the game
336    /// subtracts up to 5 random years), its commander's name, and the settlement and province of the region its
337    /// commander stands on. Forces at sea use their faction's capital; at sea without capital, the history is empty.
338    fn write_history(&self, force: &PlannedForce) -> Result<RecordNode> {
339        let layout = self.inputs.layout;
340        let index = self.inputs.index;
341        let commander = &self.forces.characters()[force.commander()];
342        let (_, cell) = self.characters.position(commander)?;
343        let region = self.inputs.region_map.region_at(cell)
344            .and_then(|key| index.region(key))
345            .or_else(|| index.capital_of(force.faction().key()));
346
347        let mut record = layout.builder(HISTORY_PATH)?;
348        let mut localisation = child(layout, HISTORY_PATH, "CAMPAIGN_LOCALISATION")?;
349        let Some(region) = region else {
350            localisation.group()?.set_str("^+1", "")?;
351            let mut date = child(layout, HISTORY_PATH, "DATE")?;
352            date.group()?.set_u32("^+1", 0)?;
353            record.group()?
354                .set_record("DATE", Some(date.build()?))?
355                .set_record("CHARACTER_NAME", Some(self.characters.write_name(HISTORY_PATH, None)?))?
356                .set_record("CAMPAIGN_LOCALISATION", Some(localisation.build()?))?
357                .set_str("CAMPAIGN_LOCALISATION+1", "")?;
358            return record.build();
359        };
360
361        let settlement_key = index.settlement_of(region.key()).and_then(|settlement| self.settlement_keys.get(settlement.id())).cloned().unwrap_or_default();
362        localisation.group()?.set_str("^+1", &settlement_name_key(&settlement_key, region))?.unset("^+2")?;
363        let mut date = child(layout, HISTORY_PATH, "DATE")?;
364        date.group()?.set_u32("^+1", self.start_year)?;
365        record.group()?
366            .set_record("DATE", Some(date.build()?))?
367            .set_record("CHARACTER_NAME", Some(self.characters.write_name(HISTORY_PATH, commander.entry())?))?
368            .set_record("CAMPAIGN_LOCALISATION", Some(localisation.build()?))?
369            .set_str("CAMPAIGN_LOCALISATION+1", self.provinces.get(region.key()).map(String::as_str).unwrap_or_default())?;
370        record.build()
371    }
372
373    /// Writes a unit: its id, key, recruitment date, owner, men and action points.
374    ///
375    /// Units start with `soldiers` percent of their men. Their action points are the base ones, raised by
376    /// [`GENERAL_ACTION_POINTS_MULTIPLIER`] in generals' forces. The commander's own unit stores the commander's id.
377    fn write_unit(&self, force: &PlannedForce, unit: &PlannedUnit, id: u32, commander: u32) -> Result<RecordNode> {
378        let layout = self.inputs.layout;
379        let stats = self.stats.get(unit.unit()).copied().unwrap_or_default();
380        let multiplier = if matches!(force.kind(), ForceKind::Army | ForceKind::Navy) { GENERAL_ACTION_POINTS_MULTIPLIER } else { 1.0 };
381        let action_points = (stats.action_points as f32 * multiplier).round() as u32;
382
383        let mut key = child(layout, UNIT_PATH, "UNIT_RECORD_KEY")?;
384        key.group()?.set_str("^+1", unit.unit())?;
385
386        let mut date = child(layout, &format!("{UNIT_PATH}/UNIT_HISTORY"), "DATE")?;
387        date.group()?.set_u32("^+1", self.start_year)?;
388        let mut history = child(layout, UNIT_PATH, "UNIT_HISTORY")?;
389        history.group()?.set_record("DATE", Some(date.build()?))?;
390
391        let mut commander_details = child(layout, UNIT_PATH, "COMMANDER_DETAILS")?;
392        commander_details.group()?.set_str("^+1", force.faction().key())?;
393
394        let mut record = layout.builder(UNIT_PATH)?;
395        record.group()?
396            .set_u32("^+1", id)?
397            .set_record("UNIT_RECORD_KEY", Some(key.build()?))?
398            .set_record("UNIT_HISTORY", Some(history.build()?))?
399            .set_record("COMMANDER_DETAILS", Some(commander_details.build()?))?
400            .set_u32("COMMANDER_DETAILS+1", stats.men * unit.soldiers() / 100)?
401            .set_u32("COMMANDER_DETAILS+2", stats.men)?
402            .set_u32("COMMANDER_DETAILS+3", action_points)?
403            .set_u32("COMMANDER_DETAILS+4", commander)?
404            .set_u32("CAMPAIGN_LOCALISATION+1", stats.action_points)?
405            .set_f32("CAMPAIGN_LOCALISATION+2", multiplier)?
406            .set_u32("CAMPAIGN_LOCALISATION+3", id.saturating_sub(1))?;
407        if let Some(bonuses) = self.write_stat_bonuses(force, unit)? {
408            record.group()?.set_record("UNIT_STATISTICS_BONUSES", Some(bonuses))?;
409        }
410        record.build()
411    }
412
413    /// Writes the stat bonuses of a garrison unit from its faction's buildings in the province (see
414    /// [`StatBonusRules`]), or `None` if it has none.
415    fn write_stat_bonuses(&self, force: &PlannedForce, unit: &PlannedUnit) -> Result<Option<RecordNode>> {
416        let Some(region) = force.region() else { return Ok(None) };
417        let province = self.provinces.get(region);
418        let index = self.inputs.index;
419        let owned = index.regions_of(force.faction().key()).filter(|other| province.is_some() && self.provinces.get(other.key()) == province);
420        let buildings = owned.filter_map(|other| index.settlement_of(other.key()).map(|settlement| (settlement, other.key() == region)))
421            .flat_map(|(settlement, own_region)| settlement.all_buildings().filter(|building| !building.is_empty()).map(move |building| (building, own_region)))
422            .collect::<Vec<_>>();
423        let bonuses = self.stat_bonuses.bonuses(unit.unit(), buildings);
424        if bonuses.is_empty() {
425            return Ok(None);
426        }
427
428        let layout = self.inputs.layout;
429        let path = format!("{UNIT_PATH}/UNIT_STATISTICS_BONUSES");
430        let block_path = format!("{path}/STATS_BLOCK");
431        let mut block = child(layout, &path, "STATS_BLOCK")?;
432        for (stat, value) in bonuses {
433            let mut bonus = child(layout, &block_path, "UNIT_STATISTICS_BONUS")?;
434            bonus.group()?
435                .set_i32("^+1", i32::try_from(stat).unwrap_or_default())?
436                .set_i32("^+2", 0)?
437                .set_i32("^+3", i32::try_from(value).unwrap_or_default())?;
438            block.push_group()?.set_record("UNIT_STATISTICS_BONUS", Some(bonus.build()?))?;
439        }
440        let mut record = layout.builder(&path)?;
441        record.group()?.set_record("STATS_BLOCK", Some(block.build()?))?;
442        record.build().map(Some)
443    }
444}
445
446/// Returns if a force is a navy.
447fn is_navy(force: &PlannedForce) -> bool {
448    matches!(force.kind(), ForceKind::Navy | ForceKind::GarrisonNavy)
449}
450
451/// Plans the horde details of the horde armies: the generals' armies of horde factions (factions that can be
452/// hordes and own no regions).
453///
454/// Recruitment manager, slot and building ids are counters over the horde armies in force order. Buildings fill the
455/// slots in column order, without gaps for empty columns. Armies without a
456/// `start_pos_horde_details` row get empty slots, and a recruitment manager id after the ones of the provinces and
457/// seas.
458fn horde_plans(inputs: &WorldInputs, forces: &ForcePlan, late_recruitment: u32) -> Result<HashMap<u32, HordePlan>> {
459    let rows = horde_details(inputs)?.into_iter()
460        .map(|row| Ok((row.str("general")?.into_owned(), row)))
461        .collect::<Result<HashMap<_, _>>>()?;
462
463    let mut horde_factions = HashMap::new();
464    let (mut next_recruitment, mut late_recruitment, mut next_slot, mut next_building) = (1, late_recruitment, 1, 1);
465    let mut plans = HashMap::new();
466    for force in forces.forces().iter().filter(|force| force.kind() == ForceKind::Army) {
467        let faction = force.faction();
468        if !horde_factions.contains_key(faction.key()) {
469            let is_horde = faction_summary(inputs.tables, inputs.index, faction)?.horde() == Some(true);
470            horde_factions.insert(faction.key().to_owned(), is_horde);
471        }
472        if !horde_factions[faction.key()] {
473            continue;
474        }
475
476        let row = forces.characters()[force.commander()].entry().and_then(|entry| rows.get(entry.id()));
477        let mut buildings = vec![];
478        for column in HORDE_BUILDING_COLUMNS {
479            if let Some(building) = row.map(|row| row.str(column)).transpose()?.filter(|building| !building.is_empty()) {
480                buildings.push(Some((building.into_owned(), next_id(&mut next_building))));
481            }
482        }
483        buildings.resize(HORDE_BUILDING_COLUMNS.len(), None);
484
485        let first_slot_id = next_slot;
486        next_slot += u32::try_from(HORDE_BUILDING_COLUMNS.len()).unwrap_or_default();
487        plans.insert(force.id(), HordePlan {
488            recruitment_id: if row.is_some() { next_id(&mut next_recruitment) } else { next_id(&mut late_recruitment) },
489            first_slot_id,
490            buildings,
491            development: u32::try_from(row.map(|row| row.int("starting_dev_points")).transpose()?.unwrap_or_default()).unwrap_or_default() + 1,
492            government: start_pos_faction_row(inputs.tables, faction)?.str("government_type")?.into_owned(),
493        });
494    }
495    Ok(plans)
496}
497
498/// Returns the men and base action points of every unit, by `main_units` key.
499fn unit_stats(inputs: &WorldInputs) -> Result<HashMap<String, UnitStats>> {
500    let mut land_points = HashMap::new();
501    for row in inputs.tables.table("land_units")?.rows() {
502        land_points.insert(row.str("key")?.into_owned(), row.int("campaign_action_points")?);
503    }
504    let mut naval_points = HashMap::new();
505    for row in inputs.tables.table("naval_units")?.rows() {
506        naval_points.insert(row.str("key")?.into_owned(), row.int("campaign_action_points")?);
507    }
508
509    let mut stats = HashMap::new();
510    for row in inputs.tables.table("main_units")?.rows() {
511        let points = if row.bool("is_naval")? { naval_points.get(row.str("naval_unit")?.as_ref()) } else { land_points.get(row.str("land_unit")?.as_ref()) };
512        stats.insert(row.str("unit")?.into_owned(), UnitStats {
513            men: u32::try_from(row.int("num_men")?).unwrap_or_default(),
514            action_points: points.and_then(|points| u32::try_from(*points).ok()).unwrap_or_default(),
515        });
516    }
517    Ok(stats)
518}
519
520/// Returns the campaign's start year.
521pub(crate) fn start_year(inputs: &WorldInputs) -> Result<u32> {
522    Ok(Calendar::new(inputs.tables, inputs.index.campaign())?.start_year())
523}