flatland-client-lib 0.2.36

Flatland3 remote game client library (TCP session, bots, game state)
Documentation
//! Authored world overlays at the player position — growth, tax, claim land, biome, plot.
//!
//! Add new [`WorldZoneLayer`] variants and collectors here so gfx/TUI status lines stay in sync.

use flatland_protocol::{
    humanize_snake_id, BiomeZoneView, GrowthZoneView, PropertyZoneView, TaxZoneView,
    ZoneRectView,
};

use crate::GameState;

/// Display order for status-line chips (lower sorts first).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum WorldZoneLayer {
    Growth,
    Biome,
    Claim,
    Tax,
    YourPlot,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorldZoneChip {
    pub layer: WorldZoneLayer,
    pub text: String,
}

pub fn zone_friendly_label(id: &str, label: Option<&str>) -> String {
    label
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .unwrap_or_else(|| humanize_snake_id(&id.replace('-', "_")))
}

pub(crate) fn zone_rects_contain(rects: &[ZoneRectView], x: f32, y: f32) -> bool {
    rects
        .iter()
        .any(|r| x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1)
}

fn zone_at<'a, Z>(
    zones: &'a [Z],
    x: f32,
    y: f32,
    rects: impl Fn(&Z) -> &[ZoneRectView],
    z_order: impl Fn(&Z) -> i32,
) -> Option<&'a Z> {
    zones
        .iter()
        .enumerate()
        .filter(|(_, z)| zone_rects_contain(rects(z), x, y))
        .max_by(|(ia, a), (ib, b)| z_order(a).cmp(&z_order(b)).then(ia.cmp(ib)))
        .map(|(_, z)| z)
}

impl GameState {
    pub fn growth_zone_at(&self, x: f32, y: f32) -> Option<&GrowthZoneView> {
        zone_at(
            &self.growth_zones,
            x,
            y,
            |z| &z.rects,
            |z| z.z_order,
        )
    }

    pub fn biome_zone_at(&self, x: f32, y: f32) -> Option<&BiomeZoneView> {
        zone_at(
            &self.biome_zones,
            x,
            y,
            |z| &z.rects,
            |z| z.z_order,
        )
    }

    /// Overlays covering the player, in stable display order.
    pub fn world_zone_chips_at_player(&self) -> Vec<WorldZoneChip> {
        let (x, y) = self.player_position();
        self.world_zone_chips_at(x, y)
    }

    pub fn world_zone_chips_at(&self, x: f32, y: f32) -> Vec<WorldZoneChip> {
        let mut chips = Vec::new();

        if let Some(z) = self.growth_zone_at(x, y) {
            let name = zone_friendly_label(&z.id, z.label.as_deref());
            let detail = if (z.fertility - 1.0).abs() > 0.05 {
                format!("{name} ({:.1}× growth)", z.fertility)
            } else {
                name
            };
            chips.push(WorldZoneChip {
                layer: WorldZoneLayer::Growth,
                text: format!("Growth: {detail}"),
            });
        }

        if let Some(z) = self.biome_zone_at(x, y) {
            let name = z
                .label
                .as_deref()
                .filter(|s| !s.trim().is_empty())
                .map(|s| s.to_string())
                .unwrap_or_else(|| humanize_snake_id(&z.biome_id));
            chips.push(WorldZoneChip {
                layer: WorldZoneLayer::Biome,
                text: format!("Biome: {name}"),
            });
        }

        if let Some(z) = self.property_zone_at(x, y) {
            chips.push(WorldZoneChip {
                layer: WorldZoneLayer::Claim,
                text: format!("Claim: {}", property_zone_chip_label(z)),
            });
        }

        if let Some(z) = self.tax_zone_at(x, y) {
            chips.push(WorldZoneChip {
                layer: WorldZoneLayer::Tax,
                text: format!("Tax: {}", tax_zone_chip_label(z)),
            });
        }

        if let Some(plot) = self
            .property_plots
            .iter()
            .find(|p| p.is_mine && point_in_plot(x, y, p))
        {
            let name = plot
                .zone_label
                .as_deref()
                .filter(|s| !s.trim().is_empty())
                .map(|s| s.to_string())
                .unwrap_or_else(|| "your plot".to_string());
            chips.push(WorldZoneChip {
                layer: WorldZoneLayer::YourPlot,
                text: format!("Plot: {name}"),
            });
        }

        chips.sort_by_key(|c| c.layer);
        chips
    }

    /// Compact status suffix (`Growth: … · Tax: …`). Empty when no overlays apply.
    pub fn world_zones_status_line(&self) -> String {
        self.world_zone_chips_at_player()
            .into_iter()
            .map(|c| c.text)
            .collect::<Vec<_>>()
            .join(" · ")
    }
}

fn property_zone_chip_label(zone: &PropertyZoneView) -> String {
    zone_friendly_label(&zone.id, zone.label.as_deref())
}

fn tax_zone_chip_label(zone: &TaxZoneView) -> String {
    let name = zone_friendly_label(&zone.id, zone.label.as_deref());
    let mut parts = vec![name];
    if zone.rate_bps > 0 {
        let pct = zone.rate_bps as f32 / 100.0;
        if (pct - pct.round()).abs() < 0.05 {
            parts.push(format!("{:.0}%", pct));
        } else {
            parts.push(format!("{pct:.1}%"));
        }
    }
    if zone.flat_copper > 0 {
        parts.push(format!("+{}¢", zone.flat_copper));
    }
    parts.join(" ")
}

fn point_in_plot(x: f32, y: f32, plot: &flatland_protocol::PropertyPlotView) -> bool {
    x >= plot.x0 && x < plot.x1 && y >= plot.y0 && y < plot.y1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tax_chip_formats_rate() {
        let zone = TaxZoneView {
            id: "mill-fields-tax".into(),
            label: Some("Mill fields tax".into()),
            rects: vec![],
            z_order: 0,
            rate_bps: 400,
            flat_copper: 0,
            market_sales_tax_bps: 0,
            market_sales_flat_copper: 0,
        };
        assert_eq!(tax_zone_chip_label(&zone), "Mill fields tax 4%");
    }

    #[test]
    fn zone_friendly_label_prefers_designer_label() {
        assert_eq!(
            zone_friendly_label("mill-fields-growth", Some("Mill fields")),
            "Mill fields"
        );
        assert_eq!(
            zone_friendly_label("mill-fields-growth", None),
            "Mill Fields Growth"
        );
    }
}