dotzuki_engine/link/mod.rs
1//! Link-play transport seam — game-agnostic and zero-I/O.
2//!
3//! Any JRPG on this engine can do link play (battle, trade, … between two
4//! players) by plugging a [`NetworkTransport`] implementation into its link
5//! state machines. The engine defines the transport interface; each game
6//! defines its own wire protocol — the message type `M` (e.g. a serde enum
7//! serialized as JSON lines over TCP).
8//!
9//! This module is deliberately **zero-I/O**: it defines the trait, the shared
10//! [`TransportError`], the [`LinkRole`] connection-side identity, and an
11//! in-memory [`ChannelTransport`] pair for local/testing use. Real transports
12//! (TCP sockets, Web `BroadcastChannel`, …) live in the game or platform
13//! layer, never here. The one exception is [`codec`]: the newline-framed
14//! JSON codec every JSON-line transport shares. It is pure serde (no I/O),
15//! so it lives here where native and wasm transports can both build on it —
16//! keeping the framing byte-identical across transports.
17//!
18//! ## What lives here vs. in the game
19//!
20//! * **Engine (this module):** the [`NetworkTransport<M>`] trait,
21//! [`TransportError`], [`ChannelTransport<M>`], [`LinkRole`] (which
22//! side clocks/hosts the connection — needed by any asymmetric handshake),
23//! and the shared JSON-line [`codec`] ([`codec::encode_line`] /
24//! [`codec::decode_line`] / [`codec::Frame`]).
25//! * **Game:** the wire message type `M`, the link state machines (battles,
26//! trades, …), and the concrete transports that implement this trait.
27//!
28//! ## Why `NetworkTransport<M>` (a type parameter, not an associated type)
29//!
30//! The message type is a plain generic parameter so implementations stay
31//! one line (`impl<M> NetworkTransport<M> for ChannelTransport<M>`), trait
32//! objects read as `dyn NetworkTransport<MyMessage>`, and the trait is usable
33//! with several protocols if a transport ever needs that. An associated type
34//! (`type Message`) would be equally valid — each transport would then own
35//! exactly one message type — but the parameter was chosen for the least
36//! ceremony across implementors.
37//!
38//! ## Usage
39//!
40//! ```text
41//! // Game side: the wire protocol (a serde enum, a byte protocol, …).
42//! enum MyMessage { Hello, Bye }
43//!
44//! // In-memory pair for local play / tests.
45//! let (mut t_a, mut t_b) = ChannelTransport::<MyMessage>::new_pair();
46//! t_a.send(MyMessage::Hello).unwrap();
47//! assert_eq!(t_b.recv().unwrap(), MyMessage::Hello);
48//! ```
49
50// `ChannelTransport` is the only mpsc-backed piece: an in-memory mock for
51// hosted local play / tests. bare-metal targets have no std::sync::mpsc, so
52// the mock is host-only; the `NetworkTransport` trait, errors, and codec
53// remain available everywhere.
54
55#[cfg(not(target_os = "none"))]
56use std::sync::mpsc;
57
58pub mod codec;
59
60/// Errors a [`NetworkTransport`] can report.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum TransportError {
63 /// The peer is gone (socket closed, channel dropped, …).
64 Disconnected,
65 /// The operation timed out.
66 Timeout,
67 /// The message could not be (de)serialized on the wire.
68 SerializationError(String),
69 /// An underlying I/O failure (hosted by the implementing transport).
70 IoError(String),
71}
72
73impl core::fmt::Display for TransportError {
74 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
75 match self {
76 TransportError::Disconnected => write!(f, "peer disconnected"),
77 TransportError::Timeout => write!(f, "operation timed out"),
78 TransportError::SerializationError(e) => write!(f, "serialization error: {}", e),
79 TransportError::IoError(e) => write!(f, "I/O error: {}", e),
80 }
81 }
82}
83
84/// A bidirectional message transport for link play.
85///
86/// Implementors are the wire half of a link connection: they move whole
87/// messages of the game's protocol type `M` in and out, hiding framing,
88/// channels, threads, and sockets. The methods mirror `std::sync::mpsc`
89/// semantics: [`Self::recv`] blocks until a message or a disconnect arrives,
90/// [`Self::try_recv`] never blocks.
91///
92/// The engine defines this interface; each game implements it for its own
93/// wire message type (see the [module docs](self)).
94pub trait NetworkTransport<M> {
95 /// Send one message to the peer.
96 fn send(&mut self, msg: M) -> Result<(), TransportError>;
97
98 /// Block until a message arrives or the connection fails.
99 fn recv(&mut self) -> Result<M, TransportError>;
100
101 /// Non-blocking receive: `Ok(None)` when nothing is pending,
102 /// `Err(Disconnected)` once the peer is gone.
103 fn try_recv(&mut self) -> Result<Option<M>, TransportError>;
104}
105
106/// An in-memory [`NetworkTransport`] pair over `std::sync::mpsc` channels.
107///
108/// The engine's built-in mock: the two ends of one link connection with
109/// zero I/O, used for local play and tests. Dropping one end makes the
110/// other report [`TransportError::Disconnected`] exactly like a closed
111/// socket.
112///
113/// Host-only: bare-metal targets (no std::sync::mpsc) implement
114/// [`NetworkTransport`] directly against their link hardware instead.
115#[cfg(not(target_os = "none"))]
116pub struct ChannelTransport<M> {
117 tx: mpsc::Sender<M>,
118 rx: mpsc::Receiver<M>,
119}
120
121#[cfg(not(target_os = "none"))]
122impl<M> ChannelTransport<M> {
123 /// Create a connected pair — the two ends of one link connection.
124 pub fn new_pair() -> (Self, Self) {
125 let (tx_a, rx_b) = mpsc::channel();
126 let (tx_b, rx_a) = mpsc::channel();
127 (
128 ChannelTransport { tx: tx_a, rx: rx_a },
129 ChannelTransport { tx: tx_b, rx: rx_b },
130 )
131 }
132}
133
134#[cfg(not(target_os = "none"))]
135impl<M> NetworkTransport<M> for ChannelTransport<M> {
136 fn send(&mut self, msg: M) -> Result<(), TransportError> {
137 self.tx.send(msg).map_err(|_| TransportError::Disconnected)
138 }
139
140 fn recv(&mut self) -> Result<M, TransportError> {
141 self.rx.recv().map_err(|_| TransportError::Disconnected)
142 }
143
144 fn try_recv(&mut self) -> Result<Option<M>, TransportError> {
145 match self.rx.try_recv() {
146 Ok(msg) => Ok(Some(msg)),
147 Err(mpsc::TryRecvError::Empty) => Ok(None),
148 Err(mpsc::TryRecvError::Disconnected) => Err(TransportError::Disconnected),
149 }
150 }
151}
152
153/// Which side of a link connection the local player is.
154///
155/// Link protocols with an asymmetric handshake need a host/guest
156/// distinction — the original Game Boy link cable called it the "internal
157/// clock" (the hosting side: drives synchronization, wins
158/// simultaneous-request ties) versus the "external clock" (the joining
159/// side: starts the handshake, defers to the host). Games map this onto
160/// their own roles (the link club, for example, calls the two
161/// sides the "player" and "friend" warp spots).
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum LinkRole {
164 /// The clocking (hosting) side of the connection.
165 Host,
166 /// The joining (guest) side of the connection.
167 Guest,
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn round_trip_send_recv() {
176 let (mut a, mut b) = ChannelTransport::new_pair();
177 a.send("hello".to_string()).unwrap();
178 assert_eq!(b.recv().unwrap(), "hello");
179 b.send("world".to_string()).unwrap();
180 assert_eq!(a.recv().unwrap(), "world");
181 }
182
183 #[test]
184 fn try_recv_empty_then_message() {
185 let (mut a, mut b) = ChannelTransport::new_pair();
186 assert_eq!(a.try_recv().unwrap(), None);
187 b.send(7).unwrap();
188 assert_eq!(a.try_recv().unwrap(), Some(7));
189 }
190
191 #[test]
192 fn pair_ends_are_independent() {
193 let (mut a, mut b) = ChannelTransport::new_pair();
194 a.send(1).unwrap();
195 // Our own send is not looped back to us.
196 assert_eq!(a.try_recv().unwrap(), None);
197 assert_eq!(b.try_recv().unwrap(), Some(1));
198 }
199
200 #[test]
201 fn drop_reports_disconnected_to_peer() {
202 let (a, mut b) = ChannelTransport::<u32>::new_pair();
203 drop(a);
204 assert_eq!(b.try_recv(), Err(TransportError::Disconnected));
205 assert_eq!(b.recv(), Err(TransportError::Disconnected));
206 }
207
208 #[test]
209 fn send_after_peer_drop_reports_disconnected() {
210 let (mut a, b) = ChannelTransport::new_pair();
211 drop(b);
212 assert_eq!(a.send(1), Err(TransportError::Disconnected));
213 }
214
215 #[test]
216 fn transport_error_display() {
217 assert_eq!(
218 TransportError::Disconnected.to_string(),
219 "peer disconnected"
220 );
221 assert_eq!(TransportError::Timeout.to_string(), "operation timed out");
222 assert_eq!(
223 TransportError::SerializationError("bad json".into()).to_string(),
224 "serialization error: bad json"
225 );
226 assert_eq!(
227 TransportError::IoError("reset".into()).to_string(),
228 "I/O error: reset"
229 );
230 }
231}