Skip to main content

rpfm_extensions/startpos/world/writer/
sight.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//! Vision of the world: what regions, settlements and forces see (`LINE_OF_SIGHT`), and what each playable faction
12//! has seen (`CAMPAIGN_SHROUD`).
13//!
14//! Characters see a disk of their agent's sight radius, and settlements one of a colonel's, as the game does. The
15//! vision of regions, sea regions and the shroud are approximations: the game's exact rules aren't known, and it
16//! recomputes vision while playing.
17//!
18//! The shroud also lists the settlements a faction doesn't see yet (`CAMPAIGN_SHROUD_FILLERS`), each with what it
19//! would reveal, grouped by the tile of a 16x16 split of the map that holds them. The AI needs them to deal with
20//! other factions. A settlement reveals the cells within two hexes of the zones of its primary slots, inside the
21//! bounds of those zones widened by [`FILLER_MARGIN`]. Verified on Attila's main campaign: 150 of 186 settlements
22//! reveal exactly what the game's do (the rest differ by a few cells at the edges of the bounds, whose vertical
23//! extent the game takes from elsewhere).
24
25use std::cmp::Reverse;
26use std::collections::{BTreeMap, HashMap, HashSet};
27
28use rpfm_lib::error::Result;
29use rpfm_lib::files::esf::{Coordinates2DNode, NodeType, RecordNode};
30
31use super::{child, WorldInputs, WorldPlan};
32use crate::startpos::coords::{HexCell, MapScale};
33use crate::startpos::layout::{child_path, StartposLayout};
34use crate::startpos::settlements::SettlementLayout;
35use crate::startpos::vision::{Vision, VisionGrid, WorldBounds};
36
37#[cfg(test)] #[path = "sight_test.rs"] mod sight_test;
38
39/// Sight radius of settlements, in world units (a colonel's `agents.line_of_sight`).
40const SETTLEMENT_SIGHT: f32 = 5.0;
41
42/// Hex steps a shroud filler reveals around the zones of its settlement's primary slots.
43const FILLER_STEPS: usize = 2;
44
45/// Slots whose zones a shroud filler reveals (`SprawlCell::zone_slots` bits): the main settlement and the port.
46const FILLER_SLOTS: u32 = 0b11;
47
48/// How far the bounds of a shroud filler's vision go past its zones, in horizontal raw units.
49const FILLER_MARGIN: f32 = 16.0;
50
51/// Buckets of the hash map a tile keeps its shroud fillers in, keyed by settlement cell.
52const FILLER_BUCKETS: u32 = 4;
53
54/// Tiles per side of the split of the map the shroud fillers are grouped by.
55const FILLER_TILES: f32 = 16.0;
56
57/// How far a region sees past its own cells, in world units. Fitted to the game's region vision (about 1% of the
58/// cells differ).
59const REGION_SIGHT: f32 = 1.7;
60
61/// Quad trees of a faction's shroud: what it has seen, what it sees, and an empty one.
62const SHROUD_LAYERS: [&str; 3] = ["QUAD_TREE_BIT_ARRAY", "QUAD_TREE_BIT_ARRAY@2", "QUAD_TREE_BIT_ARRAY@3"];
63
64//---------------------------------------------------------------------------//
65//                              Types
66//---------------------------------------------------------------------------//
67
68/// Vision of the regions and settlements of the world.
69#[derive(Debug, Clone)]
70pub struct SightPlan {
71
72    /// The vision grid of the map.
73    grid: VisionGrid,
74
75    /// Scale between the map's world and raw units.
76    scale: MapScale,
77
78    /// What each region sees, by region key.
79    regions: HashMap<String, Vision>,
80
81    /// What each settlement sees, by region key.
82    settlements: HashMap<String, Vision>,
83
84    /// Shroud fillers of the settlements, in region order.
85    fillers: Vec<Filler>,
86
87    /// Size of a tile of the fillers' split of the map, in world units.
88    filler_tile_size: (f32, f32),
89}
90
91/// A settlement that fills the shroud of a faction once the faction sees it.
92#[derive(Debug, Clone)]
93struct Filler {
94
95    /// Cell of the settlement.
96    cell: HexCell,
97
98    /// Vision cell of the settlement: the faction gets the filler if it doesn't see this cell.
99    vision_cell: (u32, u32),
100
101    /// What the settlement reveals.
102    vision: Vision,
103
104    /// World bounds of what the settlement reveals.
105    bounds: WorldBounds,
106}
107
108//---------------------------------------------------------------------------//
109//                              Implementations
110//---------------------------------------------------------------------------//
111
112impl SightPlan {
113
114    /// Computes the vision of the regions and settlements.
115    pub fn new(inputs: &WorldInputs, plan: &WorldPlan) -> Self {
116        let grid = VisionGrid::new(inputs.region_map.world_size());
117        let scale = inputs.region_map.scale();
118        let cells = inputs.region_map.cells_by_region();
119
120        let mut regions = HashMap::new();
121        let mut settlements = HashMap::new();
122        let mut fillers = vec![];
123        for region in plan.regions() {
124            let corners = cells.get(region.key().as_str()).map(|cells| cells.iter().map(|cell| scale.world_position(*cell)).collect::<Vec<_>>()).unwrap_or_default();
125            regions.insert(region.key().to_owned(), grid.around(&corners, REGION_SIGHT));
126
127            let Some(settlement) = inputs.settlements.iter().find(|settlement| settlement.region() == region.key()) else { continue };
128            if let Some(position) = settlement.slots().first().and_then(|slot| slot.position()) {
129                settlements.insert(region.key().to_owned(), grid.disk(scale.world_position(position), SETTLEMENT_SIGHT));
130                let (vision, bounds) = filler_vision(&grid, &scale, settlement);
131                fillers.push(Filler { cell: position, vision_cell: grid.cell_at(scale.world_position(position)), vision, bounds });
132            }
133        }
134
135        let (width, height) = inputs.region_map.world_size();
136        let filler_tile_size = (width / FILLER_TILES, height / FILLER_TILES);
137        Self { grid, scale, regions, settlements, fillers, filler_tile_size }
138    }
139
140    /// Returns the vision grid of the map.
141    pub fn grid(&self) -> &VisionGrid {
142        &self.grid
143    }
144
145    /// Returns what a region sees.
146    pub fn region(&self, region: &str) -> Option<&Vision> {
147        self.regions.get(region)
148    }
149
150    /// Returns what a region's settlement sees.
151    pub fn settlement(&self, region: &str) -> Option<&Vision> {
152        self.settlements.get(region)
153    }
154}
155
156/// Writes a `LINE_OF_SIGHT` record under a parent record: whether it sees, the world bounds of what it sees and the
157/// area itself. Without an area, it sees nothing and has no area.
158///
159/// # Errors
160///
161/// Returns an error if the layout lacks the records.
162pub fn write_line_of_sight(layout: &StartposLayout, parent: &str, grid: &VisionGrid, vision: Option<&Vision>) -> Result<RecordNode> {
163    let bounds = vision.and_then(|vision| vision.bounds(grid)).unwrap_or_default();
164    line_of_sight(layout, parent, grid, vision, bounds)
165}
166
167/// Writes a `LINE_OF_SIGHT` record with the given world bounds.
168fn line_of_sight(layout: &StartposLayout, parent: &str, grid: &VisionGrid, vision: Option<&Vision>, bounds: WorldBounds) -> Result<RecordNode> {
169    let path = format!("{parent}/LINE_OF_SIGHT");
170    let mut record = layout.builder(&path)?;
171    let Some(vision) = vision else {
172        record.group()?
173            .set_bool("^+1", false)?
174            .unset("^+2")?
175            .unset("^+3")?
176            .set_record("QUAD_TREE_BIT_ARRAY", None)?;
177        return record.build();
178    };
179
180    let ((min_x, min_y), (max_x, max_y)) = bounds;
181    record.group()?
182        .set_bool("^+1", true)?
183        .set("^+2", coordinates(min_x, min_y))?
184        .set("^+3", coordinates(max_x, max_y))?
185        .set_record("QUAD_TREE_BIT_ARRAY", Some(write_quad_tree(layout, &path, grid, vision)?))?;
186    record.build()
187}
188
189/// Writes the shroud of a playable faction (`CAMPAIGN_SHROUD`): everything it sees is also what it has seen, and the
190/// settlements it doesn't see fill it later.
191///
192/// # Arguments
193///
194/// * `layout` - Layout of the startpos.
195/// * `faction_path` - Path of the faction record.
196/// * `sight` - Vision of the world.
197/// * `vision` - What the faction sees.
198///
199/// # Errors
200///
201/// Returns an error if the layout lacks the records.
202pub fn write_shroud(layout: &StartposLayout, faction_path: &str, sight: &SightPlan, vision: &Vision) -> Result<RecordNode> {
203    let path = format!("{faction_path}/CAMPAIGN_SHROUD");
204    let layers = [vision, vision, &Vision::default()];
205    let mut record = layout.builder(&path)?;
206    let group = record.group()?;
207    for (key, layer) in SHROUD_LAYERS.iter().zip(layers) {
208        group.set_record(key, Some(write_quad_tree(layout, &path, &sight.grid, layer)?))?;
209    }
210    group.set_record("CAMPAIGN_SHROUD_FILLERS", Some(write_fillers(layout, &path, sight, vision)?))?;
211    record.build()
212}
213
214/// Writes the shroud fillers of a faction: the settlements outside its vision, by tile from the top row of tiles
215/// down, each tile with the world bounds of itself and what its settlements reveal, and its settlements in the order
216/// of its hash map.
217fn write_fillers(layout: &StartposLayout, shroud_path: &str, sight: &SightPlan, vision: &Vision) -> Result<RecordNode> {
218    let path = child_path(shroud_path, "CAMPAIGN_SHROUD_FILLERS");
219    let block_path = child_path(&path, "FILLERS_QUADTREE_DATA_BLOCK");
220    let los_path = child_path(&block_path, "FILLERS_QUADTREE_DATA_LOS_BLOCK");
221    let (tile_width, tile_height) = sight.filler_tile_size;
222
223    let mut tiles = BTreeMap::<(Reverse<u32>, u32), Vec<&Filler>>::new();
224    for filler in sight.fillers.iter().filter(|filler| !vision.contains(filler.vision_cell)) {
225        let (x, y) = sight.scale.world_position(filler.cell);
226        tiles.entry((Reverse((y / tile_height) as u32), (x / tile_width) as u32)).or_default().push(filler);
227    }
228
229    let mut block = layout.builder(&block_path)?;
230    for ((Reverse(row), column), mut fillers) in tiles {
231        fillers.sort_by_key(|filler| filler_bucket(filler.cell));
232        let (mut min_x, mut min_y) = (column as f32 * tile_width, row as f32 * tile_height);
233        let (mut max_x, mut max_y) = (min_x + tile_width, min_y + tile_height);
234        let mut los_block = layout.builder(&los_path)?;
235        for filler in fillers {
236            let ((x0, y0), (x1, y1)) = filler.bounds;
237            (min_x, min_y, max_x, max_y) = (min_x.min(x0), min_y.min(y0), max_x.max(x1), max_y.max(y1));
238            let line_of_sight = line_of_sight(layout, &los_path, &sight.grid, Some(&filler.vision), filler.bounds)?;
239            los_block.push_children(vec![NodeType::U16(filler.cell.x()), NodeType::U16(filler.cell.y()), NodeType::Record(Box::new(line_of_sight))]);
240        }
241        block.push_children(vec![coordinates(min_x, min_y), coordinates(max_x, max_y), NodeType::Record(Box::new(los_block.build()?))]);
242    }
243
244    let mut record = layout.builder(&path)?;
245    record.group()?.set_record("FILLERS_QUADTREE_DATA_BLOCK", Some(block.build()?))?;
246    record.build()
247}
248
249/// Returns the bucket of a settlement cell in a tile's hash map of shroud fillers, which lists them bucket by bucket
250/// (each bucket in region order).
251fn filler_bucket(cell: HexCell) -> u32 {
252    (u32::from(cell.y()) * 0x3f8 + u32::from(cell.x())) % FILLER_BUCKETS
253}
254
255/// Returns what a settlement reveals as a shroud filler, and the world bounds of it: the cells within
256/// [`FILLER_STEPS`] hexes of its primary slots' zones, inside the zones' bounds widened by [`FILLER_MARGIN`].
257fn filler_vision(grid: &VisionGrid, scale: &MapScale, settlement: &SettlementLayout) -> (Vision, WorldBounds) {
258    let zones = settlement.sprawl().iter().filter(|cell| cell.zone_slots() & FILLER_SLOTS != 0).map(|cell| cell.cell()).collect::<HashSet<_>>();
259    if zones.is_empty() {
260        return (Vision::default(), ((0.0, 0.0), (0.0, 0.0)));
261    }
262    let corners = zones.iter().map(|cell| scale.world_position(*cell)).collect::<Vec<_>>();
263    let min = corners.iter().fold((f32::MAX, f32::MAX), |min, corner| (min.0.min(corner.0), min.1.min(corner.1)));
264    let max = corners.iter().fold((f32::MIN, f32::MIN), |max, corner| (max.0.max(corner.0), max.1.max(corner.1)));
265
266    let mut revealed = zones;
267    for _ in 0..FILLER_STEPS {
268        let ring = revealed.iter().flat_map(|cell| cell.neighbours()).collect::<Vec<_>>();
269        revealed.extend(ring);
270    }
271    let margin = FILLER_MARGIN * scale.x();
272    let min = (min.0 - margin, min.1 - margin);
273    let max = (max.0 + margin, max.1 + margin);
274    grid.cells_where(min, max, |(x, y)| revealed.contains(&scale.cell_at(x, y)))
275}
276
277/// Writes a `QUAD_TREE_BIT_ARRAY` record under a parent record.
278fn write_quad_tree(layout: &StartposLayout, parent: &str, grid: &VisionGrid, vision: &Vision) -> Result<RecordNode> {
279    let (width, height, tree_size) = grid.dimensions();
280    let mut record = child(layout, parent, "QUAD_TREE_BIT_ARRAY")?;
281    record.group()?
282        .set_u32("^+1", width)?
283        .set_u32("^+2", height)?
284        .set_u32("^+3", tree_size)?
285        .set_bytes("^+4", vision.encode(grid))?;
286    record.build()
287}
288
289/// Returns a `Coord2d` leaf.
290fn coordinates(x: f32, y: f32) -> NodeType {
291    let mut node = Coordinates2DNode::default();
292    node.set_x(x);
293    node.set_y(y);
294    NodeType::Coord2d(node)
295}