1use flatland_protocol::{
6 humanize_snake_id, BiomeZoneView, GrowthZoneView, PropertyZoneView, TaxZoneView, ZoneRectView,
7};
8
9use crate::GameState;
10
11#[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 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 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}