Skip to main content

flatland_client_lib/
world_zones.rs

1//! Authored world overlays at the player position — growth, tax, claim land, biome, plot.
2//!
3//! Add new [`WorldZoneLayer`] variants and collectors here so gfx/TUI status lines stay in sync.
4
5use flatland_protocol::{
6    humanize_snake_id, BiomeZoneView, GrowthZoneView, PropertyZoneView, TaxZoneView, ZoneRectView,
7};
8
9use crate::GameState;
10
11/// Display order for status-line chips (lower sorts first).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub enum WorldZoneLayer {
14    Growth,
15    Biome,
16    Claim,
17    Tax,
18    YourPlot,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct WorldZoneChip {
23    pub layer: WorldZoneLayer,
24    pub text: String,
25}
26
27pub fn zone_friendly_label(id: &str, label: Option<&str>) -> String {
28    label
29        .map(str::trim)
30        .filter(|s| !s.is_empty())
31        .map(|s| s.to_string())
32        .unwrap_or_else(|| humanize_snake_id(&id.replace('-', "_")))
33}
34
35pub(crate) fn zone_rects_contain(rects: &[ZoneRectView], x: f32, y: f32) -> bool {
36    rects
37        .iter()
38        .any(|r| x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1)
39}
40
41fn zone_at<'a, Z>(
42    zones: &'a [Z],
43    x: f32,
44    y: f32,
45    rects: impl Fn(&Z) -> &[ZoneRectView],
46    z_order: impl Fn(&Z) -> i32,
47) -> Option<&'a Z> {
48    zones
49        .iter()
50        .enumerate()
51        .filter(|(_, z)| zone_rects_contain(rects(z), x, y))
52        .max_by(|(ia, a), (ib, b)| z_order(a).cmp(&z_order(b)).then(ia.cmp(ib)))
53        .map(|(_, z)| z)
54}
55
56impl GameState {
57    pub fn growth_zone_at(&self, x: f32, y: f32) -> Option<&GrowthZoneView> {
58        zone_at(&self.growth_zones, x, y, |z| &z.rects, |z| z.z_order)
59    }
60
61    pub fn biome_zone_at(&self, x: f32, y: f32) -> Option<&BiomeZoneView> {
62        zone_at(&self.biome_zones, x, y, |z| &z.rects, |z| z.z_order)
63    }
64
65    /// Overlays covering the player, in stable display order.
66    pub fn world_zone_chips_at_player(&self) -> Vec<WorldZoneChip> {
67        let (x, y) = self.player_position();
68        self.world_zone_chips_at(x, y)
69    }
70
71    pub fn world_zone_chips_at(&self, x: f32, y: f32) -> Vec<WorldZoneChip> {
72        let mut chips = Vec::new();
73
74        if let Some(z) = self.growth_zone_at(x, y) {
75            let name = zone_friendly_label(&z.id, z.label.as_deref());
76            let detail = if (z.fertility - 1.0).abs() > 0.05 {
77                format!("{name} ({:.1}× growth)", z.fertility)
78            } else {
79                name
80            };
81            chips.push(WorldZoneChip {
82                layer: WorldZoneLayer::Growth,
83                text: format!("Growth: {detail}"),
84            });
85        }
86
87        if let Some(z) = self.biome_zone_at(x, y) {
88            let name = z
89                .label
90                .as_deref()
91                .filter(|s| !s.trim().is_empty())
92                .map(|s| s.to_string())
93                .unwrap_or_else(|| humanize_snake_id(&z.biome_id));
94            chips.push(WorldZoneChip {
95                layer: WorldZoneLayer::Biome,
96                text: format!("Biome: {name}"),
97            });
98        }
99
100        if let Some(z) = self.property_zone_at(x, y) {
101            chips.push(WorldZoneChip {
102                layer: WorldZoneLayer::Claim,
103                text: format!("Claim: {}", property_zone_chip_label(z)),
104            });
105        }
106
107        if let Some(z) = self.tax_zone_at(x, y) {
108            chips.push(WorldZoneChip {
109                layer: WorldZoneLayer::Tax,
110                text: format!("Tax: {}", tax_zone_chip_label(z)),
111            });
112        }
113
114        if let Some(plot) = self
115            .property_plots
116            .iter()
117            .find(|p| p.is_mine && point_in_plot(x, y, p))
118        {
119            let name = plot
120                .zone_label
121                .as_deref()
122                .filter(|s| !s.trim().is_empty())
123                .map(|s| s.to_string())
124                .unwrap_or_else(|| "your plot".to_string());
125            chips.push(WorldZoneChip {
126                layer: WorldZoneLayer::YourPlot,
127                text: format!("Plot: {name}"),
128            });
129        }
130
131        chips.sort_by_key(|c| c.layer);
132        chips
133    }
134
135    /// Compact status suffix (`Growth: … · Tax: …`). Empty when no overlays apply.
136    pub fn world_zones_status_line(&self) -> String {
137        self.world_zone_chips_at_player()
138            .into_iter()
139            .map(|c| c.text)
140            .collect::<Vec<_>>()
141            .join(" · ")
142    }
143}
144
145fn property_zone_chip_label(zone: &PropertyZoneView) -> String {
146    zone_friendly_label(&zone.id, zone.label.as_deref())
147}
148
149fn tax_zone_chip_label(zone: &TaxZoneView) -> String {
150    let name = zone_friendly_label(&zone.id, zone.label.as_deref());
151    let mut parts = vec![name];
152    if zone.rate_bps > 0 {
153        let pct = zone.rate_bps as f32 / 100.0;
154        if (pct - pct.round()).abs() < 0.05 {
155            parts.push(format!("{:.0}%", pct));
156        } else {
157            parts.push(format!("{pct:.1}%"));
158        }
159    }
160    if zone.flat_copper > 0 {
161        parts.push(format!("+{}¢", zone.flat_copper));
162    }
163    parts.join(" ")
164}
165
166fn point_in_plot(x: f32, y: f32, plot: &flatland_protocol::PropertyPlotView) -> bool {
167    x >= plot.x0 && x < plot.x1 && y >= plot.y0 && y < plot.y1
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn tax_chip_formats_rate() {
176        let zone = TaxZoneView {
177            id: "mill-fields-tax".into(),
178            label: Some("Mill fields tax".into()),
179            rects: vec![],
180            z_order: 0,
181            rate_bps: 400,
182            flat_copper: 0,
183            market_sales_tax_bps: 0,
184            market_sales_flat_copper: 0,
185        };
186        assert_eq!(tax_zone_chip_label(&zone), "Mill fields tax 4%");
187    }
188
189    #[test]
190    fn zone_friendly_label_prefers_designer_label() {
191        assert_eq!(
192            zone_friendly_label("mill-fields-growth", Some("Mill fields")),
193            "Mill fields"
194        );
195        assert_eq!(
196            zone_friendly_label("mill-fields-growth", None),
197            "Mill Fields Growth"
198        );
199    }
200}