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