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