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>(
51    codec: &impl Codec,
52    payload: T,
53) -> Result<Vec<u8>, CodecError> {
54    codec.encode(&Envelope::new(payload))
55}
56
57pub fn decode_envelope<T: DeserializeOwned>(
58    codec: &impl Codec,
59    bytes: &[u8],
60) -> Result<Envelope<T>, CodecError> {
61    codec.decode(bytes)
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::types::{
68        BlueprintIngredientView, BlueprintView, EntityState, TickDelta, ToolRequirementView,
69        Transform, WorldClock, WorldCoord,
70    };
71
72    fn sample_tick_delta() -> TickDelta {
73        TickDelta {
74            tick: 42,
75            entities: (0..15)
76                .map(|i| EntityState {
77                    id: i + 1,
78                    label: String::new(),
79                    transform: Transform {
80                        position: WorldCoord::surface(i as f32, i as f32 * 0.5),
81                        yaw: 1.25,
82                        velocity: crate::types::Velocity2D { vx: 0.5, vy: -0.2 },
83                    },
84                    vitals: None,
85                    attributes: None,
86                    skills: None,
87                    inside_building: None,
88                    tile_id: None,
89                    paperdoll_ref: None,
90                    draw_scale: 1.0,
91                    presentation_state: None,
92                    sprite_mode: None,
93                    progression_xp: None,
94                    combat_cues: Vec::new(),
95                    statuses: Vec::new(),
96                })
97                .collect(),
98            resource_nodes: Vec::new(),
99            buildings: Vec::new(),
100            doors: Vec::new(),
101            npcs: Vec::new(),
102            inventory: Vec::new(),
103            blueprints: Vec::new(),
104            building_materials: Vec::new(),
105            world_clock: WorldClock::default(),
106            ground_drops: Vec::new(),
107            placed_containers: Vec::new(),
108            combat: None,
109            interior_map: None,
110            quest_log: vec![],
111            hired_workers: Vec::new(),
112            interactables: vec![],
113            ledger: None,
114            career: None,
115            combat_fx: Vec::new(),
116            ground_hazards: Vec::new(),
117            property_plots: Vec::new(),
118            terrain_overlays: Vec::new(),
119        }
120    }
121
122    #[test]
123    fn postcard_blueprint_snapshot_roundtrip() {
124        let codec = PostcardCodec;
125        let delta = TickDelta {
126            tick: 1,
127            entities: vec![],
128            resource_nodes: vec![],
129            buildings: vec![],
130            doors: vec![],
131            npcs: vec![],
132            inventory: vec![],
133            blueprints: vec![BlueprintView {
134                id: "oak_to_lumber".into(),
135                label: "Saw Planks".into(),
136                output: "lumber".into(),
137                output_qty: 1,
138                craft_ticks: 60,
139                station: Some("woodworking".into()),
140                category: Some("woodworking".into()),
141                required_tools: vec![ToolRequirementView {
142                    item: "handsaw".into(),
143                    consumed: false,
144                    display_name: "Handsaw".into(),
145                }],
146                skill: None,
147                failure_chance: 0.0,
148                worker_train_copper: 0,
149                output_display_name: "Lumber".into(),
150                craft_tier: 1,
151                inputs: vec![BlueprintIngredientView {
152                    template_id: "oak_log".into(),
153                    quantity: 2,
154                    consumed: true,
155                    display_name: "Oak Log".into(),
156                }],
157            }],
158            building_materials: Vec::new(),
159            world_clock: WorldClock::default(),
160            ground_drops: vec![],
161            placed_containers: vec![],
162            combat: None,
163            interior_map: None,
164            quest_log: vec![],
165            hired_workers: Vec::new(),
166            interactables: vec![],
167            ledger: None,
168            career: None,
169            combat_fx: Vec::new(),
170            ground_hazards: Vec::new(),
171            property_plots: Vec::new(),
172            terrain_overlays: Vec::new(),
173        };
174        let bytes = codec.encode(&Envelope::new(delta.clone())).unwrap();
175        let decoded: Envelope<TickDelta> = codec.decode(&bytes).unwrap();
176        assert_eq!(decoded.payload, delta);
177    }
178
179    #[test]
180    fn postcard_roundtrip_tick_delta() {
181        let codec = PostcardCodec;
182        let original = Envelope::new(sample_tick_delta());
183        let bytes = codec.encode(&original).expect("encode");
184        let decoded: Envelope<TickDelta> = codec.decode(&bytes).expect("decode");
185        assert_eq!(original, decoded);
186    }
187
188    #[test]
189    fn bincode_roundtrip_tick_delta() {
190        let codec = BincodeCodec;
191        let original = Envelope::new(sample_tick_delta());
192        let bytes = codec.encode(&original).expect("encode");
193        let decoded: Envelope<TickDelta> = codec.decode(&bytes).expect("decode");
194        assert_eq!(original, decoded);
195    }
196
197    #[test]
198    fn postcard_many_entities_vitals_none() {
199        let delta = TickDelta {
200            tick: 42,
201            entities: (0..15)
202                .map(|i| EntityState {
203                    id: i + 1,
204                    label: String::new(),
205                    transform: Transform {
206                        position: WorldCoord::surface(i as f32, i as f32 * 0.5),
207                        yaw: 1.25,
208                        velocity: crate::types::Velocity2D { vx: 0.5, vy: -0.2 },
209                    },
210                    vitals: None,
211                    attributes: None,
212                    skills: None,
213                    inside_building: None,
214                    tile_id: None,
215                    paperdoll_ref: None,
216                    draw_scale: 1.0,
217                    presentation_state: None,
218                    sprite_mode: None,
219                    progression_xp: None,
220                    combat_cues: Vec::new(),
221                    statuses: Vec::new(),
222                })
223                .collect(),
224            resource_nodes: vec![],
225            buildings: vec![],
226            doors: vec![],
227            npcs: vec![],
228            inventory: vec![],
229            blueprints: vec![],
230            building_materials: vec![],
231            world_clock: WorldClock::default(),
232            ground_drops: vec![],
233            placed_containers: vec![],
234            combat: None,
235            interior_map: None,
236            quest_log: vec![],
237            hired_workers: Vec::new(),
238            interactables: vec![],
239            ledger: None,
240            career: None,
241            combat_fx: Vec::new(),
242            ground_hazards: Vec::new(),
243            property_plots: Vec::new(),
244            terrain_overlays: Vec::new(),
245        };
246        let codec = PostcardCodec;
247        let bytes = codec.encode(&Envelope::new(delta.clone())).unwrap();
248        let decoded: Envelope<TickDelta> = codec.decode(&bytes).unwrap();
249        assert_eq!(decoded.payload, delta);
250    }
251}