1use 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
21const 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
35pub const MAX_PLAYERS: usize = PLAYER_PALETTE.len();
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
41pub enum ColorSelectionMode {
42 #[default]
44 Default,
45 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
67pub trait ServerSink: Send {
69 fn send(&self, msg: ServerMsg) -> bool;
70}
71
72#[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#[derive(Debug, Clone)]
109pub struct Hello {
110 pub name: String,
111 pub color: RgbColor,
112}
113
114pub 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 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 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 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
260pub 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}