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#[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#[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}