Skip to main content

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}