Skip to main content

flatland_protocol/
codec.rs

1use serde::{de::DeserializeOwned, Serialize};
2
3use crate::Envelope;
4
5#[derive(Debug, thiserror::Error)]
6pub enum CodecError {
7    #[error("postcard encode failed: {0}")]
8    PostcardEncode(postcard::Error),
9    #[error("postcard decode failed: {0}")]
10    PostcardDecode(postcard::Error),
11    #[error("bincode encode failed: {0}")]
12    BincodeEncode(bincode::Error),
13    #[error("bincode decode failed: {0}")]
14    BincodeDecode(bincode::Error),
15}
16
17pub trait Codec {
18    fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>, CodecError>;
19    fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CodecError>;
20}
21
22/// Default on-wire codec for game traffic (compact, serde-native).
23#[derive(Debug, Clone, Copy, Default)]
24pub struct PostcardCodec;
25
26impl Codec for PostcardCodec {
27    fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>, CodecError> {
28        postcard::to_allocvec(value).map_err(CodecError::PostcardEncode)
29    }
30
31    fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CodecError> {
32        postcard::from_bytes(bytes).map_err(CodecError::PostcardDecode)
33    }
34}
35
36/// Alternative codec used in benchmarks and for comparison.
37#[derive(Debug, Clone, Copy, Default)]
38pub struct BincodeCodec;
39
40impl Codec for BincodeCodec {
41    fn encode<T: Serialize>(&self, value: &T) -> Result<Vec<u8>, CodecError> {
42        bincode::serialize(value).map_err(CodecError::BincodeEncode)
43    }
44
45    fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CodecError> {
46        bincode::deserialize(bytes).map_err(CodecError::BincodeDecode)
47    }
48}
49
50pub fn encode_envelope<T: Serialize>(codec: &impl Codec, payload: T) -> Result<Vec<u8>, CodecError> {
51    codec.encode(&Envelope::new(payload))
52}
53
54pub fn decode_envelope<T: DeserializeOwned>(
55    codec: &impl Codec,
56    bytes: &[u8],
57) -> Result<Envelope<T>, CodecError> {
58    codec.decode(bytes)
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::types::{
65        BlueprintIngredientView, BlueprintView, EntityState, TickDelta, ToolRequirementView,
66        Transform, WorldClock, WorldCoord,
67    };
68
69    fn sample_tick_delta() -> TickDelta {
70        TickDelta {
71            tick: 42,
72            entities: (0..15)
73                .map(|i| EntityState {
74                    id: i + 1,
75                    label: String::new(),
76                    transform: Transform {
77                        position: WorldCoord::surface(i as f32, i as f32 * 0.5),
78                        yaw: 1.25,
79                        velocity: crate::types::Velocity2D { vx: 0.5, vy: -0.2 },
80                    },
81                    vitals: None,
82                    attributes: None,
83                skills: None,
84                    inside_building: None,
85                    tile_id: None,
86                    presentation_state: None,
87                    sprite_mode: None,
88                })
89                .collect(),
90            resource_nodes: Vec::new(),
91            buildings: Vec::new(),
92            doors: Vec::new(),
93            npcs: Vec::new(),
94            inventory: Vec::new(),
95            blueprints: Vec::new(),
96            world_clock: WorldClock::default(),
97            ground_drops: Vec::new(),
98            placed_containers: Vec::new(),
99            combat: None,
100            interior_map: None,
101            quest_log: vec![],
102            interactables: vec![],
103        }
104    }
105
106    #[test]
107    fn postcard_blueprint_snapshot_roundtrip() {
108        let codec = PostcardCodec;
109        let delta = TickDelta {
110            tick: 1,
111            entities: vec![],
112            resource_nodes: vec![],
113            buildings: vec![],
114            doors: vec![],
115            npcs: vec![],
116            inventory: vec![],
117            blueprints: vec![BlueprintView {
118                id: "oak_to_lumber".into(),
119                label: "Saw Planks".into(),
120                output: "lumber".into(),
121                output_qty: 1,
122                craft_ticks: 60,
123                station: Some("woodworking".into()),
124                category: Some("woodworking".into()),
125                required_tools: vec![ToolRequirementView {
126                    item: "handsaw".into(),
127                    consumed: false,
128                }],
129                skill: None,
130                failure_chance: 0.0,
131                inputs: vec![BlueprintIngredientView {
132                    template_id: "oak_log".into(),
133                    quantity: 2,
134                    consumed: true,
135                }],
136            }],
137            world_clock: WorldClock::default(),
138            ground_drops: vec![],
139            placed_containers: vec![],
140            combat: None,
141            interior_map: None,
142            quest_log: vec![],
143            interactables: vec![],
144        };
145        let bytes = codec.encode(&Envelope::new(delta.clone())).unwrap();
146        let decoded: Envelope<TickDelta> = codec.decode(&bytes).unwrap();
147        assert_eq!(decoded.payload, delta);
148    }
149
150    #[test]
151    fn postcard_roundtrip_tick_delta() {
152        let codec = PostcardCodec;
153        let original = Envelope::new(sample_tick_delta());
154        let bytes = codec.encode(&original).expect("encode");
155        let decoded: Envelope<TickDelta> = codec.decode(&bytes).expect("decode");
156        assert_eq!(original, decoded);
157    }
158
159    #[test]
160    fn bincode_roundtrip_tick_delta() {
161        let codec = BincodeCodec;
162        let original = Envelope::new(sample_tick_delta());
163        let bytes = codec.encode(&original).expect("encode");
164        let decoded: Envelope<TickDelta> = codec.decode(&bytes).expect("decode");
165        assert_eq!(original, decoded);
166    }
167
168    #[test]
169    fn postcard_many_entities_vitals_none() {
170        let delta = TickDelta {
171            tick: 42,
172            entities: (0..15)
173                .map(|i| EntityState {
174                    id: i + 1,
175                    label: String::new(),
176                    transform: Transform {
177                        position: WorldCoord::surface(i as f32, i as f32 * 0.5),
178                        yaw: 1.25,
179                        velocity: crate::types::Velocity2D { vx: 0.5, vy: -0.2 },
180                    },
181                    vitals: None,
182                    attributes: None,
183                skills: None,
184                    inside_building: None,
185                    tile_id: None,
186                    presentation_state: None,
187                    sprite_mode: None,
188                })
189                .collect(),
190            resource_nodes: vec![],
191            buildings: vec![],
192            doors: vec![],
193            npcs: vec![],
194            inventory: vec![],
195            blueprints: vec![],
196            world_clock: WorldClock::default(),
197            ground_drops: vec![],
198            placed_containers: vec![],
199            combat: None,
200            interior_map: None,
201            quest_log: vec![],
202            interactables: vec![],
203        };
204        let codec = PostcardCodec;
205        let bytes = codec.encode(&Envelope::new(delta.clone())).unwrap();
206        let decoded: Envelope<TickDelta> = codec.decode(&bytes).unwrap();
207        assert_eq!(decoded.payload, delta);
208    }
209}