Skip to main content

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