Skip to main content

dartboard_local/
lib.rs

1//! In-process dartboard server + LocalClient.
2//!
3//! The server owns the canonical [`Canvas`], assigns globally monotonic
4//! sequence numbers, and fans out [`ServerMsg`]s to connected clients. Each
5//! [`LocalClient`] is a handle scoped to one session. Other transports can
6//! integrate by implementing [`ServerSink`] and using
7//! [`ServerHandle::register_transport`].
8
9use std::sync::mpsc;
10use std::sync::{Arc, Mutex};
11
12use dartboard_core::{
13    Canvas, CanvasOp, Client, ClientMsg, ClientOpId, Peer, RgbColor, Seq, ServerMsg, UserId,
14};
15use rand::seq::SliceRandom;
16
17pub mod store;
18
19pub use store::{CanvasStore, InMemStore};
20
21/// Candidate player colors. Kept in sync with the client-side palette in
22/// `dartboard/src/theme.rs`.
23const PLAYER_PALETTE: [RgbColor; 9] = [
24    RgbColor::new(255, 110, 64),
25    RgbColor::new(255, 236, 96),
26    RgbColor::new(145, 226, 88),
27    RgbColor::new(72, 220, 170),
28    RgbColor::new(84, 196, 255),
29    RgbColor::new(128, 163, 255),
30    RgbColor::new(192, 132, 255),
31    RgbColor::new(255, 124, 196),
32    RgbColor::new(176, 48, 56),
33];
34
35/// Max concurrent players on a single shared canvas. Equal to the palette
36/// length so the seat cap preserves unique per-user colors.
37pub const MAX_PLAYERS: usize = PLAYER_PALETTE.len();
38
39/// Policy for assigning per-user colors on connect.
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
41pub enum ColorSelectionMode {
42    /// Workspace default color-selection policy.
43    #[default]
44    Default,
45    /// Assign a color uniformly from the palette entries not currently in use.
46    RandomUnique,
47}
48
49fn select_user_color(mode: ColorSelectionMode, used: &[RgbColor]) -> RgbColor {
50    let available: Vec<_> = PLAYER_PALETTE
51        .iter()
52        .copied()
53        .filter(|color| !used.contains(color))
54        .collect();
55    debug_assert!(
56        !available.is_empty(),
57        "color selection requires an unused palette slot"
58    );
59
60    match mode {
61        ColorSelectionMode::Default | ColorSelectionMode::RandomUnique => *available
62            .choose(&mut rand::thread_rng())
63            .expect("unused color should exist before reaching MAX_PLAYERS"),
64    }
65}
66
67/// Minimal transport-facing sink used by the server to fan out [`ServerMsg`]s.
68pub trait ServerSink: Send {
69    fn send(&self, msg: ServerMsg) -> bool;
70}
71
72/// A handle to the running server. Cloneable; every clone references the same
73/// canonical canvas and client registry.
74#[derive(Clone)]
75pub struct ServerHandle {
76    inner: Arc<ServerInner>,
77}
78
79struct ServerInner {
80    state: Mutex<State>,
81}
82
83struct State {
84    canvas: Canvas,
85    seq: Seq,
86    next_user_id: UserId,
87    color_selection_mode: ColorSelectionMode,
88    clients: Vec<ClientEntry>,
89    store: Box<dyn CanvasStore>,
90}
91
92struct ClientEntry {
93    peer: Peer,
94    sender: Box<dyn ServerSink>,
95}
96
97struct LocalSink(mpsc::Sender<ServerMsg>);
98
99impl ServerSink for LocalSink {
100    fn send(&self, msg: ServerMsg) -> bool {
101        self.0.send(msg).is_ok()
102    }
103}
104
105/// Introductory payload a client sends before any ops. Name is echoed back to
106/// peers; color is a transport-level hint and may be replaced by the server's
107/// configured [`ColorSelectionMode`].
108#[derive(Debug, Clone)]
109pub struct Hello {
110    pub name: String,
111    pub color: RgbColor,
112}
113
114/// Outcome of a connect attempt. Rejected connections leave no registered
115/// state on the server.
116pub enum ConnectOutcome {
117    Accepted(LocalClient),
118    Rejected(String),
119}
120
121impl ServerHandle {
122    pub fn spawn_local<S: CanvasStore + 'static>(store: S) -> Self {
123        Self::spawn_local_with_color_selection_mode(store, ColorSelectionMode::default())
124    }
125
126    pub fn spawn_local_with_color_selection_mode<S: CanvasStore + 'static>(
127        store: S,
128        color_selection_mode: ColorSelectionMode,
129    ) -> Self {
130        let canvas = store.load().unwrap_or_default();
131        let inner = Arc::new(ServerInner {
132            state: Mutex::new(State {
133                canvas,
134                seq: 0,
135                next_user_id: 1,
136                color_selection_mode,
137                clients: Vec::new(),
138                store: Box::new(store),
139            }),
140        });
141        Self { inner }
142    }
143
144    pub fn try_connect_local(&self, hello: Hello) -> ConnectOutcome {
145        let (tx, rx) = mpsc::channel();
146        match self.register_transport(hello, Box::new(LocalSink(tx))) {
147            Ok(user_id) => ConnectOutcome::Accepted(LocalClient {
148                server: self.clone(),
149                user_id,
150                rx,
151                next_client_op_id: 1,
152            }),
153            Err(reason) => ConnectOutcome::Rejected(reason),
154        }
155    }
156
157    pub fn connect_local(&self, hello: Hello) -> LocalClient {
158        match self.try_connect_local(hello) {
159            ConnectOutcome::Accepted(client) => client,
160            ConnectOutcome::Rejected(reason) => {
161                panic!("connect_local rejected: {reason}")
162            }
163        }
164    }
165
166    /// Register a new client against an existing transport sink.
167    pub fn register_transport(
168        &self,
169        hello: Hello,
170        sender: Box<dyn ServerSink>,
171    ) -> Result<UserId, String> {
172        let mut state = self.inner.state.lock().unwrap();
173        if state.clients.len() >= MAX_PLAYERS {
174            let reason = format!(
175                "dartboard is full ({} / {} players)",
176                state.clients.len(),
177                MAX_PLAYERS
178            );
179            let _ = sender.send(ServerMsg::ConnectRejected {
180                reason: reason.clone(),
181            });
182            return Err(reason);
183        }
184        let user_id = state.next_user_id;
185        state.next_user_id += 1;
186
187        let used_colors: Vec<RgbColor> = state.clients.iter().map(|c| c.peer.color).collect();
188        let color = select_user_color(state.color_selection_mode, &used_colors);
189
190        let peer = Peer {
191            user_id,
192            name: hello.name,
193            color,
194        };
195
196        sender.send(ServerMsg::Welcome {
197            your_user_id: user_id,
198            your_color: color,
199            peers: state.clients.iter().map(|c| c.peer.clone()).collect(),
200            snapshot: state.canvas.clone(),
201        });
202
203        for entry in &state.clients {
204            entry
205                .sender
206                .send(ServerMsg::PeerJoined { peer: peer.clone() });
207        }
208
209        state.clients.push(ClientEntry { peer, sender });
210        Ok(user_id)
211    }
212
213    pub fn peer_count(&self) -> usize {
214        self.inner.state.lock().unwrap().clients.len()
215    }
216
217    pub fn canvas_snapshot(&self) -> Canvas {
218        self.inner.state.lock().unwrap().canvas.clone()
219    }
220
221    /// Apply an op on behalf of an already-registered user.
222    pub fn submit_op_for(&self, user_id: UserId, client_op_id: ClientOpId, op: CanvasOp) {
223        let mut state = self.inner.state.lock().unwrap();
224
225        let State {
226            canvas,
227            seq,
228            clients,
229            store,
230            ..
231        } = &mut *state;
232
233        canvas.apply(&op);
234        *seq += 1;
235        let seq = *seq;
236        store.save(canvas);
237
238        for entry in clients.iter() {
239            if entry.peer.user_id == user_id {
240                entry.sender.send(ServerMsg::Ack { client_op_id, seq });
241            }
242            entry.sender.send(ServerMsg::OpBroadcast {
243                from: user_id,
244                op: op.clone(),
245                seq,
246            });
247        }
248    }
249
250    /// Disconnect a previously-registered user and broadcast `PeerLeft`.
251    pub fn disconnect_user(&self, user_id: UserId) {
252        let mut state = self.inner.state.lock().unwrap();
253        state.clients.retain(|c| c.peer.user_id != user_id);
254        for entry in &state.clients {
255            entry.sender.send(ServerMsg::PeerLeft { user_id });
256        }
257    }
258}
259
260/// In-process client handle. Sends ops directly into the server under the
261/// shared state lock; receives events over a std mpsc channel.
262pub struct LocalClient {
263    server: ServerHandle,
264    user_id: UserId,
265    rx: mpsc::Receiver<ServerMsg>,
266    next_client_op_id: ClientOpId,
267}
268
269impl LocalClient {
270    pub fn user_id(&self) -> UserId {
271        self.user_id
272    }
273
274    pub fn send(&mut self, msg: ClientMsg) -> Option<ClientOpId> {
275        match msg {
276            ClientMsg::Hello { .. } => None,
277            ClientMsg::Op { op, .. } => Some(self.submit_op(op)),
278        }
279    }
280}
281
282impl Client for LocalClient {
283    fn submit_op(&mut self, op: CanvasOp) -> ClientOpId {
284        let id = self.next_client_op_id;
285        self.next_client_op_id += 1;
286        self.server.submit_op_for(self.user_id, id, op);
287        id
288    }
289
290    fn try_recv(&mut self) -> Option<ServerMsg> {
291        self.rx.try_recv().ok()
292    }
293}
294
295impl Drop for LocalClient {
296    fn drop(&mut self) {
297        self.server.disconnect_user(self.user_id);
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use dartboard_core::{ops::RowShift, Pos};
305
306    fn red() -> RgbColor {
307        RgbColor::new(255, 0, 0)
308    }
309
310    fn blue() -> RgbColor {
311        RgbColor::new(0, 0, 255)
312    }
313
314    fn drain_events(client: &mut LocalClient) -> Vec<ServerMsg> {
315        let mut events = Vec::new();
316        while let Some(msg) = client.try_recv() {
317            events.push(msg);
318        }
319        events
320    }
321
322    #[test]
323    fn welcome_contains_snapshot_and_existing_peers() {
324        let server = ServerHandle::spawn_local(InMemStore);
325        let mut alice = server.connect_local(Hello {
326            name: "alice".into(),
327            color: red(),
328        });
329        let mut bob = server.connect_local(Hello {
330            name: "bob".into(),
331            color: blue(),
332        });
333
334        let alice_events = drain_events(&mut alice);
335        let bob_events = drain_events(&mut bob);
336
337        match &alice_events[0] {
338            ServerMsg::Welcome { peers, .. } => assert!(peers.is_empty()),
339            other => panic!("expected Welcome, got {:?}", other),
340        }
341        match &bob_events[0] {
342            ServerMsg::Welcome { peers, .. } => {
343                assert_eq!(peers.len(), 1);
344                assert_eq!(peers[0].name, "alice");
345            }
346            other => panic!("expected Welcome, got {:?}", other),
347        }
348        assert!(alice_events
349            .iter()
350            .any(|m| matches!(m, ServerMsg::PeerJoined { .. })));
351    }
352
353    #[test]
354    fn submit_op_broadcasts_and_acks() {
355        let server = ServerHandle::spawn_local(InMemStore);
356        let mut alice = server.connect_local(Hello {
357            name: "alice".into(),
358            color: red(),
359        });
360        let mut bob = server.connect_local(Hello {
361            name: "bob".into(),
362            color: blue(),
363        });
364        let _ = drain_events(&mut alice);
365        let _ = drain_events(&mut bob);
366
367        alice.submit_op(CanvasOp::PaintCell {
368            pos: Pos { x: 2, y: 1 },
369            ch: 'A',
370            fg: red(),
371        });
372
373        let alice_events = drain_events(&mut alice);
374        let bob_events = drain_events(&mut bob);
375
376        assert!(alice_events
377            .iter()
378            .any(|m| matches!(m, ServerMsg::Ack { .. })));
379        assert!(alice_events
380            .iter()
381            .any(|m| matches!(m, ServerMsg::OpBroadcast { .. })));
382        assert!(bob_events
383            .iter()
384            .any(|m| matches!(m, ServerMsg::OpBroadcast { .. })));
385
386        let snap = server.canvas_snapshot();
387        assert_eq!(snap.get(Pos { x: 2, y: 1 }), 'A');
388    }
389
390    #[test]
391    fn sequence_numbers_are_monotonic() {
392        let server = ServerHandle::spawn_local(InMemStore);
393        let mut client = server.connect_local(Hello {
394            name: "solo".into(),
395            color: red(),
396        });
397        let _ = drain_events(&mut client);
398
399        client.submit_op(CanvasOp::PaintCell {
400            pos: Pos { x: 0, y: 0 },
401            ch: 'A',
402            fg: red(),
403        });
404        client.submit_op(CanvasOp::PaintCell {
405            pos: Pos { x: 1, y: 0 },
406            ch: 'B',
407            fg: red(),
408        });
409
410        let mut seqs = Vec::new();
411        for msg in drain_events(&mut client) {
412            if let ServerMsg::OpBroadcast { seq, .. } = msg {
413                seqs.push(seq);
414            }
415        }
416        assert_eq!(seqs, vec![1, 2]);
417    }
418
419    #[test]
420    fn shift_row_op_is_applied_server_side() {
421        let server = ServerHandle::spawn_local(InMemStore);
422        let mut client = server.connect_local(Hello {
423            name: "solo".into(),
424            color: red(),
425        });
426        let _ = drain_events(&mut client);
427
428        client.submit_op(CanvasOp::PaintCell {
429            pos: Pos { x: 0, y: 0 },
430            ch: 'A',
431            fg: red(),
432        });
433        client.submit_op(CanvasOp::PaintCell {
434            pos: Pos { x: 1, y: 0 },
435            ch: 'B',
436            fg: red(),
437        });
438        client.submit_op(CanvasOp::ShiftRow {
439            y: 0,
440            kind: RowShift::PushLeft { to_x: 1 },
441        });
442
443        let snap = server.canvas_snapshot();
444        assert_eq!(snap.get(Pos { x: 0, y: 0 }), 'B');
445        assert_eq!(snap.get(Pos { x: 1, y: 0 }), ' ');
446    }
447
448    #[test]
449    fn joining_players_get_unique_colors_from_palette() {
450        let server = ServerHandle::spawn_local(InMemStore);
451        let mut alice = server.connect_local(Hello {
452            name: "alice".into(),
453            color: red(),
454        });
455        let mut bob = server.connect_local(Hello {
456            name: "bob".into(),
457            color: red(),
458        });
459
460        let alice_color = match drain_events(&mut alice).into_iter().next() {
461            Some(ServerMsg::Welcome { your_color, .. }) => your_color,
462            other => panic!("expected Welcome, got {:?}", other),
463        };
464        assert!(PLAYER_PALETTE.contains(&alice_color));
465
466        let bob_color = match drain_events(&mut bob).into_iter().next() {
467            Some(ServerMsg::Welcome { your_color, .. }) => your_color,
468            other => panic!("expected Welcome, got {:?}", other),
469        };
470        assert_ne!(
471            bob_color, alice_color,
472            "players should never collide while free palette slots remain"
473        );
474        assert!(
475            PLAYER_PALETTE.contains(&bob_color),
476            "assigned color {:?} should come from the palette",
477            bob_color
478        );
479    }
480
481    #[test]
482    fn dropping_client_broadcasts_peer_left() {
483        let server = ServerHandle::spawn_local(InMemStore);
484        let mut alice = server.connect_local(Hello {
485            name: "alice".into(),
486            color: red(),
487        });
488        let alice_id;
489        {
490            let bob = server.connect_local(Hello {
491                name: "bob".into(),
492                color: blue(),
493            });
494            alice_id = alice.user_id();
495            drop(bob);
496        }
497        let events = drain_events(&mut alice);
498        assert!(
499            events
500                .iter()
501                .any(|m| matches!(m, ServerMsg::PeerLeft { .. })),
502            "expected PeerLeft in {:?}",
503            events
504        );
505        assert_eq!(server.peer_count(), 1);
506        let _ = alice_id;
507    }
508
509    #[test]
510    fn next_connect_is_rejected_when_server_is_full() {
511        let server = ServerHandle::spawn_local(InMemStore);
512        let mut clients = Vec::new();
513        for (i, color) in PLAYER_PALETTE.iter().copied().enumerate().take(MAX_PLAYERS) {
514            clients.push(server.connect_local(Hello {
515                name: format!("peer{i}"),
516                color,
517            }));
518        }
519
520        match server.try_connect_local(Hello {
521            name: "overflow".into(),
522            color: red(),
523        }) {
524            ConnectOutcome::Rejected(reason) => {
525                assert!(reason.to_lowercase().contains("full"), "reason: {reason}");
526            }
527            ConnectOutcome::Accepted(_) => panic!("server should be full"),
528        }
529        assert_eq!(server.peer_count(), MAX_PLAYERS);
530    }
531}