flatland-protocol 0.2.1

Flatland3 wire protocol types and codecs
Documentation
use serde::{de::DeserializeOwned, Serialize};

use crate::Envelope;

#[derive(Debug, thiserror::Error)]
pub enum CodecError {
    #[error("postcard encode failed: {0}")]
    PostcardEncode(postcard::Error),
    #[error("postcard decode failed: {0}")]
    PostcardDecode(postcard::Error),
    #[error("bincode encode failed: {0}")]
    BincodeEncode(bincode::Error),
    #[error("bincode decode failed: {0}")]
    BincodeDecode(bincode::Error),
}

pub trait Codec {
    fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>, CodecError>;
    fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CodecError>;
}

/// Default on-wire codec for game traffic (compact, serde-native).
#[derive(Debug, Clone, Copy, Default)]
pub struct PostcardCodec;

impl Codec for PostcardCodec {
    fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>, CodecError> {
        postcard::to_allocvec(value).map_err(CodecError::PostcardEncode)
    }

    fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CodecError> {
        postcard::from_bytes(bytes).map_err(CodecError::PostcardDecode)
    }
}

/// Alternative codec used in benchmarks and for comparison.
#[derive(Debug, Clone, Copy, Default)]
pub struct BincodeCodec;

impl Codec for BincodeCodec {
    fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>, CodecError> {
        bincode::serialize(value).map_err(CodecError::BincodeEncode)
    }

    fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CodecError> {
        bincode::deserialize(bytes).map_err(CodecError::BincodeDecode)
    }
}

pub fn encode_envelope<T: Serialize>(codec: &impl Codec, payload: T) -> Result<Vec<u8>, CodecError> {
    codec.encode(&Envelope::new(payload))
}

pub fn decode_envelope<T: DeserializeOwned>(
    codec: &impl Codec,
    bytes: &[u8],
) -> Result<Envelope<T>, CodecError> {
    codec.decode(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{
        BlueprintIngredientView, BlueprintView, EntityState, TickDelta, ToolRequirementView,
        Transform, WorldClock, WorldCoord,
    };

    fn sample_tick_delta() -> TickDelta {
        TickDelta {
            tick: 42,
            entities: (0..15)
                .map(|i| EntityState {
                    id: i + 1,
                    label: String::new(),
                    transform: Transform {
                        position: WorldCoord::surface(i as f32, i as f32 * 0.5),
                        yaw: 1.25,
                        velocity: crate::types::Velocity2D { vx: 0.5, vy: -0.2 },
                    },
                    vitals: None,
                    attributes: None,
                skills: None,
                    inside_building: None,
                })
                .collect(),
            resource_nodes: Vec::new(),
            buildings: Vec::new(),
            doors: Vec::new(),
            npcs: Vec::new(),
            inventory: Vec::new(),
            blueprints: Vec::new(),
            world_clock: WorldClock::default(),
            ground_drops: Vec::new(),
            placed_containers: Vec::new(),
            combat: None,
        }
    }

    #[test]
    fn postcard_blueprint_snapshot_roundtrip() {
        let codec = PostcardCodec;
        let delta = TickDelta {
            tick: 1,
            entities: vec![],
            resource_nodes: vec![],
            buildings: vec![],
            doors: vec![],
            npcs: vec![],
            inventory: vec![],
            blueprints: vec![BlueprintView {
                id: "oak_to_lumber".into(),
                label: "Saw Planks".into(),
                output: "lumber".into(),
                output_qty: 1,
                craft_ticks: 60,
                station: Some("woodworking".into()),
                category: Some("woodworking".into()),
                required_tools: vec![ToolRequirementView {
                    item: "handsaw".into(),
                    consumed: false,
                }],
                skill: None,
                failure_chance: 0.0,
                inputs: vec![BlueprintIngredientView {
                    template_id: "oak_log".into(),
                    quantity: 2,
                    consumed: true,
                }],
            }],
            world_clock: WorldClock::default(),
            ground_drops: vec![],
            placed_containers: vec![],
            combat: None,
        };
        let bytes = codec.encode(&Envelope::new(delta.clone())).unwrap();
        let decoded: Envelope<TickDelta> = codec.decode(&bytes).unwrap();
        assert_eq!(decoded.payload, delta);
    }

    #[test]
    fn postcard_roundtrip_tick_delta() {
        let codec = PostcardCodec;
        let original = Envelope::new(sample_tick_delta());
        let bytes = codec.encode(&original).expect("encode");
        let decoded: Envelope<TickDelta> = codec.decode(&bytes).expect("decode");
        assert_eq!(original, decoded);
    }

    #[test]
    fn bincode_roundtrip_tick_delta() {
        let codec = BincodeCodec;
        let original = Envelope::new(sample_tick_delta());
        let bytes = codec.encode(&original).expect("encode");
        let decoded: Envelope<TickDelta> = codec.decode(&bytes).expect("decode");
        assert_eq!(original, decoded);
    }

    #[test]
    fn postcard_many_entities_vitals_none() {
        let delta = TickDelta {
            tick: 42,
            entities: (0..15)
                .map(|i| EntityState {
                    id: i + 1,
                    label: String::new(),
                    transform: Transform {
                        position: WorldCoord::surface(i as f32, i as f32 * 0.5),
                        yaw: 1.25,
                        velocity: crate::types::Velocity2D { vx: 0.5, vy: -0.2 },
                    },
                    vitals: None,
                    attributes: None,
                skills: None,
                    inside_building: None,
                })
                .collect(),
            resource_nodes: vec![],
            buildings: vec![],
            doors: vec![],
            npcs: vec![],
            inventory: vec![],
            blueprints: vec![],
            world_clock: WorldClock::default(),
            ground_drops: vec![],
            placed_containers: vec![],
            combat: None,
        };
        let codec = PostcardCodec;
        let bytes = codec.encode(&Envelope::new(delta.clone())).unwrap();
        let decoded: Envelope<TickDelta> = codec.decode(&bytes).unwrap();
        assert_eq!(decoded.payload, delta);
    }
}