plozone 0.1.0

3D spatial zone engine: geofencing, octree hole-scanning, realtime sync (WebSocket + QUIC + io_uring), voxel pathfinding, and AV sensor fusion.
Documentation
//! Client-side zone tracking with adaptive send rate (feature `server`).
//!
//! [`ZoneClient`] holds a local [`ZoneStore`] mirror, applies incoming server
//! diffs, and exposes callbacks for zone enter/exit/scan events. The caller
//! drives the network I/O and calls [`ZoneClient::on_server_bytes`] when data
//! arrives.

use crate::coord::EnuConverter;
use crate::net::{
    ClientMsg, CompactHole, NetworkTier, PosTracker, ServerMsg, ZoneEvent, decode, encode,
};
use crate::store::ZoneStore;
use crate::zone::ZoneEntry;

/// A local mirror of the server's zone state with event callbacks.
pub struct ZoneClient {
    pub store: ZoneStore,
    pub conv: EnuConverter,
    pub entity_id: u32,
    pub tier: NetworkTier,
    pub tracker: PosTracker,
    /// Called when the entity enters a zone.
    pub on_enter: Box<dyn Fn(u32) + Send>,
    /// Called when the entity exits a zone.
    pub on_exit: Box<dyn Fn(u32) + Send>,
    /// Called when a hole scan result arrives.
    pub on_holes: Box<dyn Fn(u32, Vec<CompactHole>) + Send>,
}

impl ZoneClient {
    pub fn new(
        entity_id: u32,
        conv: EnuConverter,
        tier: NetworkTier,
    ) -> Self {
        let store = ZoneStore::from_entries(&[], &conv);
        Self {
            store,
            conv,
            entity_id,
            tier,
            tracker: PosTracker::new(entity_id, tier),
            on_enter: Box::new(|_| {}),
            on_exit: Box::new(|_| {}),
            on_holes: Box::new(|_, _| {}),
        }
    }

    /// Feed raw bytes from the server. Decodes and applies the message.
    pub fn on_server_bytes(&mut self, bytes: &[u8]) -> Option<ServerMsg> {
        let msg: ServerMsg = decode(bytes)?;
        self.apply_server_msg(&msg);
        Some(msg)
    }

    fn apply_server_msg(&mut self, msg: &ServerMsg) {
        match msg {
            ServerMsg::ZoneBatch { diffs, .. } => {
                for diff in diffs {
                    match diff {
                        crate::store::ZoneDiff::Add(e) => {
                            self.store.add_zone(e.id, &e.zone, &self.conv);
                        }
                        crate::store::ZoneDiff::Remove { id } => {
                            self.store.remove(*id);
                        }
                        crate::store::ZoneDiff::Modify { id, zone } => {
                            self.store.remove(*id);
                            self.store.add_zone(*id, zone, &self.conv);
                        }
                        _ => {}
                    }
                }
            }
            ServerMsg::EntityEvent { zone_id, event, .. } => match event {
                ZoneEvent::Enter => (self.on_enter)(*zone_id),
                ZoneEvent::Exit => (self.on_exit)(*zone_id),
                _ => {}
            },
            ServerMsg::ScanResult { zone_id, holes, .. } => {
                (self.on_holes)(*zone_id, holes.clone());
            }
            _ => {}
        }
    }

    /// Build a position message for the current actual position.
    pub fn build_pos_msg(&mut self, pos: [f32; 3], ts_ms: u32) -> ClientMsg {
        self.tracker.build_pos_msg(pos, ts_ms)
    }

    /// Should we send an update given dead-reckoning prediction?
    pub fn needs_update(&self, actual: [f32; 3], ts_ms: u32) -> bool {
        self.tracker.needs_update(actual, ts_ms)
    }

    /// Request a full zone snapshot from the server.
    pub fn snapshot_request(&self) -> Vec<u8> {
        encode(&ClientMsg::RequestSnapshot)
    }

    /// Apply a snapshot (list of zone entries), replacing the local store.
    pub fn apply_snapshot(&mut self, entries: &[ZoneEntry]) {
        self.store = ZoneStore::from_entries(entries, &self.conv);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::store::ZoneDiff;
    use crate::zone::{Zone, ZoneEntry};

    fn test_client() -> ZoneClient {
        ZoneClient::new(42, EnuConverter::new(0.0, 0.0, 0.0), NetworkTier::Wifi)
    }

    #[test]
    fn apply_zone_batch_adds_and_removes() {
        let mut c = test_client();
        let msg = ServerMsg::ZoneBatch {
            seq: 1,
            diffs: vec![
            ZoneDiff::Add(ZoneEntry::new(
                1,
                Zone::Cylinder { center: [0.0, 0.0], radius_m: 10.0, z_min: 0.0, z_max: 5.0 },
            )),
            ZoneDiff::Add(ZoneEntry::new(
                2,
                Zone::Cylinder { center: [0.0, 0.0], radius_m: 20.0, z_min: 0.0, z_max: 5.0 },
            )),
            ],
        };
        let bytes = encode(&msg);
        c.on_server_bytes(&bytes);
        assert_eq!(c.store.len(), 2);

        let remove_msg = ServerMsg::ZoneBatch {
            seq: 2,
            diffs: vec![ZoneDiff::Remove { id: 1 }],
        };
        c.on_server_bytes(&encode(&remove_msg));
        assert_eq!(c.store.len(), 1);
        assert!(c.store.ids().contains(&2));
    }

    #[test]
    fn callbacks_fire_on_events() {
        use std::sync::{Arc, Mutex};
        let entered = Arc::new(Mutex::new(vec![]));
        let exited = Arc::new(Mutex::new(vec![]));
        let ent_clone = entered.clone();
        let ext_clone = exited.clone();
        let mut c = test_client();
        c.on_enter = Box::new(move |id| ent_clone.lock().unwrap().push(id));
        c.on_exit = Box::new(move |id| ext_clone.lock().unwrap().push(id));

        let enter = ServerMsg::EntityEvent {
            entity_id: 42,
            event: ZoneEvent::Enter,
            zone_id: 5,
            ts_ms: 100,
        };
        c.on_server_bytes(&encode(&enter));
        assert_eq!(*entered.lock().unwrap(), vec![5]);

        let exit = ServerMsg::EntityEvent {
            entity_id: 42,
            event: ZoneEvent::Exit,
            zone_id: 5,
            ts_ms: 200,
        };
        c.on_server_bytes(&encode(&exit));
        assert_eq!(*exited.lock().unwrap(), vec![5]);
    }

    #[test]
    fn build_pos_and_snapshot() {
        let mut c = test_client();
        let msg = c.build_pos_msg([1.0, 2.0, 3.0], 50);
        // First call from origin (0,0,0): delta of 1000,2000,3000 mm fits in i16.
        assert!(matches!(msg, ClientMsg::DeltaPos { .. }));

        // Big jump → FullPos.
        let msg2 = c.build_pos_msg([500.0, 2.0, 3.0], 100);
        assert!(matches!(msg2, ClientMsg::FullPos { .. }));

        let snap = c.snapshot_request();
        let decoded: ClientMsg = decode(&snap).unwrap();
        assert!(matches!(decoded, ClientMsg::RequestSnapshot));
    }
}