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