dotzuki_engine/link/codec.rs
1//! JSON-line framing codec shared by link transports.
2//!
3//! The link wire convention is one serde-JSON document per line (the same
4//! convention as the debug server). Transports add the `\n` framing
5//! themselves: a TCP transport writes the line to a socket, a Web
6//! `BroadcastChannel` transport posts it as a string. Both share
7//! [`encode_line`]/[`decode_line`] so framing stays byte-identical across
8//! transports.
9//!
10//! This module is pure serde — no I/O, no platform calls — which is why it
11//! lives in the engine (the zero-I/O layer) while the transports that use it
12//! live in the game or platform layer.
13//!
14//! Broadcast-style channels deliver every post to EVERY participant on the
15//! channel — including the sender's own — so broadcast frames are wrapped in
16//! a [`Frame`] envelope carrying a random per-session tag, and receivers
17//! drop frames whose tag is their own ([`Frame::is_self`]). The envelope is
18//! pure serde, so it is defined (and tested) here rather than inside any
19//! one transport.
20
21use serde::{Deserialize, Serialize};
22
23use super::TransportError;
24
25/// A broadcast-channel frame: the sender's per-session tag plus the protocol
26/// message.
27///
28/// Referenced by broadcast-style transports at runtime; it lives here so
29/// the envelope contract is verified once for every transport that shares
30/// the channel (native tests included).
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Frame<M> {
33 /// Random per-session tag; a frame carrying OUR tag is our own echo.
34 pub from: String,
35 /// The link protocol message.
36 pub msg: M,
37}
38
39impl<M> Frame<M> {
40 /// True when the frame was posted by us — broadcast channels echo every
41 /// message back to the sender, and each side must drop its own echo.
42 pub fn is_self(&self, my_tag: &str) -> bool {
43 self.from == my_tag
44 }
45}
46
47/// Serialize a value as one JSON line (no trailing newline — the transports
48/// add the `\n` framing themselves).
49pub fn encode_line<T: Serialize>(value: &T) -> Result<String, TransportError> {
50 serde_json::to_string(value).map_err(|e| TransportError::SerializationError(e.to_string()))
51}
52
53/// Deserialize one JSON line.
54pub fn decode_line<T: serde::de::DeserializeOwned>(line: &str) -> Result<T, TransportError> {
55 serde_json::from_str(line).map_err(|e| TransportError::SerializationError(e.to_string()))
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 /// A stand-in wire protocol: two "battle" messages and one "trade"
63 /// message, mirroring the shape of a real link protocol enum.
64 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65 #[serde(tag = "kind", rename_all = "snake_case")]
66 enum TestMessage {
67 Hello { version: u8 },
68 HelloAck { version: u8 },
69 RequestBattle,
70 }
71
72 fn hello() -> TestMessage {
73 TestMessage::Hello { version: 2 }
74 }
75
76 fn hello_ack() -> TestMessage {
77 TestMessage::HelloAck { version: 2 }
78 }
79
80 #[test]
81 fn bare_message_roundtrips_through_codec() {
82 let json = encode_line(&hello()).unwrap();
83 // One JSON document, no trailing newline (the transports add it).
84 assert!(!json.contains('\n'));
85 assert_eq!(decode_line::<TestMessage>(&json).unwrap(), hello());
86 }
87
88 #[test]
89 fn frame_roundtrips_through_codec_and_self_filter() {
90 let frame = Frame {
91 from: "abc123".to_string(),
92 msg: hello_ack(),
93 };
94 let json = encode_line(&frame).unwrap();
95 // The envelope wraps the bare message under `msg`; `from` carries
96 // the sender's tag.
97 assert!(json.contains("\"from\":\"abc123\""));
98 assert!(json.contains("\"msg\":"));
99
100 let decoded = decode_line::<Frame<TestMessage>>(&json).unwrap();
101 assert_eq!(decoded, frame);
102 // The self-echo filter: my tag drops my own frames, keeps the peer's.
103 assert!(frame.is_self("abc123"));
104 assert!(!frame.is_self("peer-tag"));
105 }
106
107 #[test]
108 fn peer_frame_with_different_tag_is_kept() {
109 let frame = Frame {
110 from: "peer-tag".to_string(),
111 msg: TestMessage::RequestBattle,
112 };
113 assert!(!frame.is_self("my-tag"));
114 let json = encode_line(&frame).unwrap();
115 assert_eq!(
116 decode_line::<Frame<TestMessage>>(&json).unwrap().msg,
117 TestMessage::RequestBattle
118 );
119 }
120
121 #[test]
122 fn malformed_line_is_a_serialization_error() {
123 match decode_line::<Frame<TestMessage>>("not json at all") {
124 Err(TransportError::SerializationError(_)) => {}
125 other => panic!("expected SerializationError, got {:?}", other),
126 }
127 // A bare message (no envelope) must not decode as a Frame.
128 let bare = encode_line(&hello()).unwrap();
129 assert!(matches!(
130 decode_line::<Frame<TestMessage>>(&bare),
131 Err(TransportError::SerializationError(_))
132 ));
133 }
134}