1#![allow(clippy::too_many_arguments)]
2use std::collections::{HashMap, HashSet};
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{mpsc as std_mpsc, Arc, Mutex};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use crate::config::{Config, DeckSelection, SelfPlayConfig};
9use crate::engine_backend::{java_backend, rust_backend, EngineBackendKind, HostedGameOver};
10use crate::shell_bridge::{ShellBridge, ShellCommand};
11use crate::updater::{run_stale_monitor, StaleConfig};
12use futures_util::stream::{SplitSink, SplitStream};
13use futures_util::{SinkExt, StreamExt};
14use manabot::{run_bot, AgentKind, BotConfig};
15use manabrew_agent_interface::ids_codec::{parse_player_slot, player_slot};
16use manabrew_agent_interface::prompt::{AgentMessage, ClientToServerMessage, PromptOutput};
17use manabrew_agent_interface::protocol::{
18 identity_token, ClientMessage, ClientPlatform, EngineKind, GameFormat, IdentityProof,
19 PlayerDeckInfo, ResumeRoomRequest, RoomInfo, RoomStatus, ServerMessage, StateEnvelope,
20 PROTOCOL_VERSION,
21};
22use manabrew_protocol::deck_dto::Deck;
23use manabrew_protocol::transport::DirectiveInput;
24use serde::Deserialize;
25use serde_json::{json, Value};
26use tokio::net::TcpStream;
27use tokio::sync::mpsc as tokio_mpsc;
28use tokio::task::JoinHandle;
29use tokio::time;
30use tokio_tungstenite::tungstenite::Message;
31use tokio_tungstenite::{connect_async_with_config, MaybeTlsStream, WebSocketStream};
32use tracing::{debug, error, info, warn};
33use uuid::Uuid;
34
35type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
36type WsWrite = SplitSink<WsStream, Message>;
37type WsRead = SplitStream<WsStream>;
38
39const SELF_HOSTED_NODE_PROTOCOL: &str = "self-hosted-node";
40const PRIVATE_MESSAGE_MISSING_PLAYER: &str =
41 "cannot forward private engine message without a mapped player";
42
43struct RelayClient {
44 username: String,
45 outbound: tokio_mpsc::UnboundedSender<Message>,
46 read: WsRead,
47 writer: JoinHandle<()>,
48}
49
50enum EngineSession {
51 Manabrew {
52 game_id: String,
53 remote_response_txs: HashMap<usize, std_mpsc::Sender<ClientToServerMessage>>,
54 engine_clock: EngineClock,
55 },
56 Forge {
57 game_id: String,
58 remote_response_txs: HashMap<usize, std_mpsc::Sender<ClientToServerMessage>>,
59 cancel: Arc<AtomicBool>,
60 engine_clock: EngineClock,
61 },
62}
63
64impl EngineSession {
65 fn game_id(&self) -> &str {
66 match self {
67 EngineSession::Manabrew { game_id, .. } | EngineSession::Forge { game_id, .. } => {
68 game_id
69 }
70 }
71 }
72
73 fn engine_clock(&self) -> &EngineClock {
74 match self {
75 EngineSession::Manabrew { engine_clock, .. }
76 | EngineSession::Forge { engine_clock, .. } => engine_clock,
77 }
78 }
79}
80
81struct BotState {
82 handle: JoinHandle<()>,
83 shutdown: Arc<tokio::sync::Notify>,
84}
85
86#[derive(Debug, Deserialize)]
87#[serde(rename_all = "camelCase")]
88struct SpawnBotPayload {
89 #[serde(default)]
90 deck: Option<SpawnBotDeckPayload>,
91 #[serde(default)]
92 decks: Option<Vec<SpawnBotDeckPayload>>,
93}
94
95#[derive(Debug, Deserialize)]
96#[serde(rename_all = "camelCase")]
97struct SpawnBotDeckPayload {
98 deck_name: String,
99 deck: Deck,
100 #[serde(default)]
101 commander_name: Option<String>,
102}
103
104type SharedEngineSession = Arc<Mutex<Option<EngineSession>>>;
105type SessionRegistry = Arc<Mutex<Vec<SharedEngineSession>>>;
106
107fn registry_idle(registry: &SessionRegistry) -> bool {
108 registry
109 .lock()
110 .map(|sessions| {
111 sessions
112 .iter()
113 .all(|session| session.lock().map(|guard| guard.is_none()).unwrap_or(false))
114 })
115 .unwrap_or(false)
116}
117
118static DRAINING: AtomicBool = AtomicBool::new(false);
119
120fn draining() -> bool {
121 DRAINING.load(Ordering::Relaxed)
122}
123
124type SharedBotState = Arc<Mutex<Vec<BotState>>>;
125
126#[derive(Clone)]
127struct GameStart {
128 game_id: String,
129 player_order: Vec<String>,
130 player_decks: Vec<PlayerDeckInfo>,
131 starting_life: i32,
132}
133
134#[derive(Default)]
135struct HostSnapshot {
136 room_info: Option<RoomInfo>,
137 resume_token: Option<String>,
138 game: Option<GameStart>,
139 last_state: Option<Value>,
140 last_state_by_slot: HashMap<String, Value>,
141 pending_prompts: HashMap<String, Value>,
142 pending_end_game: Option<String>,
143}
144
145type SharedHostSnapshot = Arc<Mutex<HostSnapshot>>;
146
147const RECONNECT_BACKOFF_SECS: [u64; 6] = [1, 2, 4, 8, 15, 30];
148
149pub(crate) const OBSERVER_SEAT: usize = usize::MAX;
151const CLOSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(3);
152const BOT_STOP_TIMEOUT: Duration = Duration::from_secs(5);
153
154enum LoopExit {
155 Cancelled,
156 Disconnected,
157}
158
159pub async fn cli_entry() {
160 {
161 use tracing_subscriber::layer::SubscriberExt;
162 use tracing_subscriber::util::SubscriberInitExt;
163 tracing_subscriber::registry()
164 .with(
165 tracing_subscriber::EnvFilter::try_from_default_env()
166 .unwrap_or_else(|_| "self_hosted_node=info".into()),
167 )
168 .with(tracing_subscriber::fmt::layer())
169 .with(crate::logs::layer_from_env())
170 .init();
171 }
172 crate::metrics::init_from_env();
173
174 if std::env::var("SELF_HOSTED_NODE_JAVA_SMOKE").is_ok() {
175 let max_prompts = std::env::var("SELF_HOSTED_NODE_JAVA_SMOKE_PROMPTS")
176 .ok()
177 .and_then(|value| value.parse().ok())
178 .unwrap_or(4);
179 if let Err(error) = java_backend::run_smoke_game(max_prompts) {
180 error!(%error, "java-forge smoke failed");
181 std::process::exit(1);
182 }
183 info!(max_prompts, "java-forge smoke completed");
184 return;
185 }
186 if std::env::var("SELF_HOSTED_NODE_GRAAL_SMOKE").is_ok() {
187 if let Err(error) = java_backend::run_graal_smoke() {
188 error!(%error, "graal-forge smoke failed");
189 std::process::exit(1);
190 }
191 info!("graal-forge smoke completed");
192 return;
193 }
194 if std::env::var("SELF_HOSTED_NODE_JAVA_CONCEDE_SMOKE").is_ok() {
195 if let Err(error) = java_backend::run_concede_smoke() {
196 error!(%error, "java-forge concede smoke failed");
197 std::process::exit(1);
198 }
199 info!("java-forge concede smoke completed");
200 return;
201 }
202 if let Ok(scenario_name) = std::env::var("SELF_HOSTED_NODE_JAVA_SCENARIO") {
203 let max_prompts = std::env::var("SELF_HOSTED_NODE_JAVA_SCENARIO_PROMPTS")
204 .ok()
205 .and_then(|value| value.parse().ok())
206 .unwrap_or(20);
207 if let Err(error) = java_backend::run_scenario(&scenario_name, max_prompts) {
208 error!(scenario_name, %error, "java-forge scenario failed");
209 std::process::exit(1);
210 }
211 info!(scenario_name, max_prompts, "java-forge scenario completed");
212 return;
213 }
214 if std::env::var("SELF_HOSTED_NODE_JAVA_SELF_PLAY").is_ok() {
215 let cfg = SelfPlayConfig::from_env();
216 let max_prompts = std::env::var("SELF_HOSTED_NODE_JAVA_SELF_PLAY_PROMPTS")
217 .ok()
218 .and_then(|value| value.parse().ok())
219 .unwrap_or(2_000);
220 let games = std::env::var("SELF_HOSTED_NODE_JAVA_SELF_PLAY_GAMES")
221 .ok()
222 .and_then(|value| value.parse().ok())
223 .unwrap_or(1);
224 if let Err(error) =
225 java_backend::run_self_play(&cfg.seats, cfg.starting_life, cfg.seed, max_prompts, games)
226 {
227 error!(%error, "java-forge self-play failed");
228 std::process::exit(1);
229 }
230 info!(
231 players = cfg.seats.len(),
232 max_prompts, games, "java-forge self-play completed"
233 );
234 return;
235 }
236 if let Ok(value) = std::env::var("SELF_HOSTED_NODE_JAVA_CONCURRENT_GAMES") {
237 let cfg = SelfPlayConfig::from_env();
238 let max_prompts = std::env::var("SELF_HOSTED_NODE_JAVA_SELF_PLAY_PROMPTS")
239 .ok()
240 .and_then(|value| value.parse().ok())
241 .unwrap_or(2_000);
242 let concurrency = value.parse().unwrap_or(2);
243 if let Err(error) = java_backend::run_concurrent_self_play(
244 &cfg.seats,
245 cfg.starting_life,
246 cfg.seed,
247 max_prompts,
248 concurrency,
249 ) {
250 error!(%error, "java-forge concurrent self-play failed");
251 std::process::exit(1);
252 }
253 info!(
254 players = cfg.seats.len(),
255 concurrency, "java-forge concurrent self-play completed"
256 );
257 return;
258 }
259 if std::env::var("SELF_HOSTED_NODE_RUST_SELF_PLAY").is_ok() {
260 let cfg = SelfPlayConfig::from_env();
261 let max_turns = std::env::var("SELF_HOSTED_NODE_RUST_SELF_PLAY_TURNS")
262 .ok()
263 .and_then(|value| value.parse().ok())
264 .unwrap_or(200);
265 if let Err(error) =
266 rust_backend::run_self_play(&cfg.seats, cfg.starting_life, cfg.seed, max_turns)
267 {
268 error!(%error, "rust self-play failed");
269 std::process::exit(1);
270 }
271 info!(
272 players = cfg.seats.len(),
273 max_turns, "rust self-play completed"
274 );
275 return;
276 }
277
278 let config = Config::from_env();
279 if let Err(error) = run(config).await {
280 error!(%error, "room node exited");
281 std::process::exit(1);
282 }
283}
284
285pub type RoomCancel = Arc<tokio::sync::Notify>;
287
288fn ensure_engine_ready(config: &Config) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
289 if config.engine_enabled
290 && config.backend.is_supported()
291 && matches!(config.backend, EngineBackendKind::Forge)
292 {
293 info!("initializing forge engine backend");
294 java_backend::init_engine()?;
295 }
296 Ok(())
297}
298
299pub async fn host_room(
302 config: Config,
303 cancel: RoomCancel,
304 ready: tokio::sync::oneshot::Sender<String>,
305) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
306 ensure_engine_ready(&config)?;
307 host_one_room(config, None, cancel, Some(ready), None, None).await
308}
309
310pub struct ShellBridgeHandle {
312 pub bridge: Arc<ShellBridge>,
313 pub commands: tokio_mpsc::UnboundedReceiver<ShellCommand>,
314}
315
316pub async fn host_room_bridged(
317 config: Config,
318 cancel: RoomCancel,
319 ready: tokio::sync::oneshot::Sender<String>,
320 bridge: ShellBridgeHandle,
321) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
322 ensure_engine_ready(&config)?;
323 host_one_room(config, None, cancel, Some(ready), None, Some(bridge)).await
324}
325
326async fn run(mut config: Config) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
327 ensure_engine_ready(&config)?;
328
329 let registry: SessionRegistry = Arc::new(Mutex::new(Vec::new()));
330
331 let slots = config.max_games.max(1);
332 let single = config.room_id.is_some() || slots <= 1;
333 let room_count = if single { 1 } else { slots };
334 let cancels: Vec<RoomCancel> = (0..room_count)
335 .map(|_| Arc::new(tokio::sync::Notify::new()))
336 .collect();
337
338 let monitor_registry = registry.clone();
339 let stale_cancels = cancels.clone();
340 tokio::spawn(run_stale_monitor(
341 StaleConfig::from_env_and_args(),
342 move || registry_idle(&monitor_registry),
343 move || notify_all(&stale_cancels),
344 ));
345
346 let signal_cancels = cancels.clone();
347 tokio::spawn(async move {
348 wait_for_shutdown_signal().await;
349 info!("shutdown signal received; closing rooms");
350 notify_all(&signal_cancels);
351 });
352
353 #[cfg(unix)]
354 tokio::spawn(async move {
355 let mut usr1 =
356 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::user_defined1()) {
357 Ok(usr1) => usr1,
358 Err(error) => {
359 warn!(%error, "failed to install SIGUSR1 handler");
360 return;
361 }
362 };
363 while usr1.recv().await.is_some() {
364 DRAINING.store(true, Ordering::Relaxed);
365 info!("drain signal received; refusing new games and closing each room once idle");
366 }
367 });
368
369 #[cfg(all(unix, feature = "graal-forge"))]
370 tokio::spawn(async move {
371 let mut usr2 =
372 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::user_defined2()) {
373 Ok(usr2) => usr2,
374 Err(error) => {
375 warn!(%error, "failed to install SIGUSR2 handler");
376 return;
377 }
378 };
379 while usr2.recv().await.is_some() {
380 let dir = std::env::var("SELF_HOSTED_NODE_HEAP_DUMP_DIR")
381 .unwrap_or_else(|_| "/tmp".to_string());
382 let stamp = std::time::SystemTime::now()
383 .duration_since(std::time::UNIX_EPOCH)
384 .map(|since| since.as_secs())
385 .unwrap_or(0);
386 let path = format!("{dir}/self-hosted-node-{stamp}.hprof");
387 info!(path, "heap dump requested");
388 match tokio::task::spawn_blocking({
389 let path = path.clone();
390 move || java_backend::dump_shared_heap(&path)
391 })
392 .await
393 {
394 Ok(Ok(())) => info!(path, "heap dump written"),
395 Ok(Err(error)) => warn!(%error, "heap dump failed"),
396 Err(error) => warn!(%error, "heap dump task failed"),
397 }
398 }
399 });
400
401 if single {
402 return host_one_room(config, None, cancels[0].clone(), None, Some(registry), None).await;
403 }
404
405 config.format = GameFormat::Any;
406 let hosts: Vec<(Config, String)> = (0..slots)
407 .map(|slot| (config.clone(), (slot + 1).to_string()))
408 .collect();
409
410 info!(rooms = hosts.len(), "hosting multiple rooms on one node");
411 let mut handles = Vec::with_capacity(hosts.len());
412 for ((cfg, label), cancel) in hosts.into_iter().zip(cancels) {
413 let registry = registry.clone();
414 handles.push(tokio::spawn(async move {
415 if let Err(error) =
416 host_one_room(cfg, Some(label.clone()), cancel, None, Some(registry), None).await
417 {
418 error!(%error, label, "room host exited");
419 }
420 }));
421 }
422 for handle in handles {
423 let _ = handle.await;
424 }
425 Ok(())
426}
427
428fn notify_all(cancels: &[RoomCancel]) {
429 for cancel in cancels {
430 cancel.notify_one();
431 }
432}
433
434async fn wait_for_shutdown_signal() {
435 #[cfg(unix)]
436 {
437 let mut term =
438 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
439 Ok(term) => term,
440 Err(error) => {
441 warn!(%error, "failed to install SIGTERM handler");
442 let _ = tokio::signal::ctrl_c().await;
443 return;
444 }
445 };
446 tokio::select! {
447 _ = tokio::signal::ctrl_c() => {}
448 _ = term.recv() => {}
449 }
450 }
451 #[cfg(not(unix))]
452 {
453 let _ = tokio::signal::ctrl_c().await;
454 }
455}
456
457async fn host_one_room(
458 mut config: Config,
459 label: Option<String>,
460 cancel: RoomCancel,
461 ready: Option<tokio::sync::oneshot::Sender<String>>,
462 sessions: Option<SessionRegistry>,
463 shell: Option<ShellBridgeHandle>,
464) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
465 if let Some(label) = &label {
466 config.username = format!("{}-{label}", config.username);
467 config.bot_username = format!("{}-{label}", config.bot_username);
468 config.room_name = format!("{} ({label})", config.room_name);
469 }
470
471 let _rooms_hosted = crate::metrics::RoomHostedGuard::new(if label.is_some() {
472 crate::metrics::PoolKind::Pod
473 } else {
474 crate::metrics::PoolKind::Solo
475 });
476
477 info!(
478 relay_url = %config.relay_url,
479 username = %config.username,
480 room_name = %config.room_name,
481 auto_start = config.auto_start,
482 engine_enabled = config.engine_enabled,
483 host_plays = config.host_plays,
484 bot_enabled = config.bot_enabled,
485 "starting room host"
486 );
487
488 let snapshot: SharedHostSnapshot = Arc::new(Mutex::new(HostSnapshot::default()));
489 let engine_session: SharedEngineSession = Arc::new(Mutex::new(None));
490 if let Some(sessions) = &sessions {
491 if let Ok(mut registered) = sessions.lock() {
492 registered.push(engine_session.clone());
493 }
494 }
495 let bot_state: SharedBotState = Arc::new(Mutex::new(Vec::new()));
496 let (outbound_tx, mut outbound_rx) = tokio_mpsc::unbounded_channel::<ClientMessage>();
497
498 let mut host =
499 RelayClient::connect(&config.relay_url, &config.username, &config.password).await?;
500 let mut room_id = establish_room(&mut host, &config, &snapshot).await?;
501
502 let (bridge, mut bridge_rx, _idle_bridge_tx) = match shell {
504 Some(handle) => (Some(handle.bridge), handle.commands, None),
505 None => {
506 let (tx, rx) = tokio_mpsc::unbounded_channel::<ShellCommand>();
507 (None, rx, Some(tx))
508 }
509 };
510 if let Some(bridge) = &bridge {
511 let reprime_snapshot = snapshot.clone();
512 let reprime_tx = outbound_tx.clone();
513 bridge.set_on_fallback(move |username| {
514 reprime_relay_cache(&reprime_snapshot, &reprime_tx, username);
515 });
516 }
517
518 if bridge.is_some() {
519 announce_webrtc_only(&config, &outbound_tx);
520 }
521
522 if let Some(ready) = ready {
523 let _ = ready.send(room_id.clone());
524 }
525
526 if config.bot_enabled {
527 spawn_bots(
528 &config,
529 std::slice::from_ref(&config.bot_deck),
530 &room_id,
531 &bot_state,
532 );
533 }
534
535 loop {
536 let exit = run_client_loop(
537 &mut host,
538 &config,
539 &room_id,
540 &engine_session,
541 &snapshot,
542 &bot_state,
543 &outbound_tx,
544 &mut outbound_rx,
545 &cancel,
546 bridge.as_ref(),
547 &mut bridge_rx,
548 )
549 .await;
550 if matches!(exit, LoopExit::Cancelled) {
551 stop_bots(&bot_state);
552 host.close().await;
553 return Ok(());
554 }
555
556 crate::metrics::record_relay_reconnect();
557 let mut attempt: usize = 0;
558 host = loop {
559 let delay = RECONNECT_BACKOFF_SECS[attempt.min(RECONNECT_BACKOFF_SECS.len() - 1)];
560 tokio::select! {
561 _ = cancel.notified() => {
562 info!(username = %config.username, "room host cancelled while reconnecting");
563 cancel_engine(&engine_session);
564 stop_bots(&bot_state);
565 return Ok(());
566 }
567 _ = time::sleep(Duration::from_secs(delay)) => {}
568 }
569 attempt += 1;
570 let mut client =
571 match RelayClient::connect(&config.relay_url, &config.username, &config.password)
572 .await
573 {
574 Ok(client) => client,
575 Err(error) => {
576 warn!(%error, attempt, "relay reconnect failed");
577 continue;
578 }
579 };
580 match reestablish_room(&mut client, &config, &engine_session, &snapshot).await {
581 Ok(new_room_id) => {
582 if new_room_id != room_id {
583 stop_bots(&bot_state);
584 if config.bot_enabled {
585 spawn_bots(
586 &config,
587 std::slice::from_ref(&config.bot_deck),
588 &new_room_id,
589 &bot_state,
590 );
591 }
592 room_id = new_room_id;
593 }
594 break client;
595 }
596 Err(error) => {
597 warn!(%error, attempt, "failed to re-establish room; retrying");
598 }
599 }
600 };
601 if let Some(bridge) = &bridge {
602 bridge.clear_game();
603 announce_webrtc_only(&config, &outbound_tx);
604 }
605 info!(username = %config.username, room_id, "relay connection re-established");
606 }
607}
608
609fn reprime_relay_cache(
611 snapshot: &SharedHostSnapshot,
612 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
613 username: &str,
614) {
615 let Some(index) = seat_index_of(snapshot, username) else {
616 return;
617 };
618 let slot = player_slot(index);
619 let Ok(snap) = snapshot.lock() else {
620 return;
621 };
622 let state = snap.last_state_by_slot.get(&slot).cloned();
623 let prompt = snap.pending_prompts.get(&slot).cloned();
624 drop(snap);
625
626 for state in [state, prompt].into_iter().flatten() {
628 let _ = outbound_tx.send(ClientMessage::BroadcastState {
629 state,
630 target_player: Some(username.to_string()),
631 });
632 }
633 info!(
634 username,
635 "re-primed the relay cache for a seat that fell back"
636 );
637}
638
639fn announce_webrtc_only(config: &Config, outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>) {
640 let endpoint = manabrew_relay_protocol::TransportEndpoint {
641 endpoint_id: format!("webrtc:{}", config.username),
642 relay_url: None,
643 direct_addrs: Vec::new(),
644 kinds: vec![manabrew_relay_protocol::TRANSPORT_KIND_WEBRTC.to_string()],
645 };
646 info!(endpoint_id = %endpoint.endpoint_id, "announcing a webrtc-only endpoint");
647 let _ = outbound_tx.send(ClientMessage::AnnounceTransport {
648 endpoint: Some(endpoint),
649 });
650}
651
652async fn establish_room(
653 client: &mut RelayClient,
654 config: &Config,
655 snapshot: &SharedHostSnapshot,
656) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
657 let room_id = if let Some(room_id) = &config.room_id {
658 client
659 .send(&ClientMessage::JoinRoom {
660 room_id: room_id.clone(),
661 observe: !config.host_plays,
662 as_bot: false,
663 password: config.room_password.clone(),
664 })
665 .await?;
666 info!(room_id, "joining configured room");
667 room_id.clone()
668 } else {
669 client
670 .send(&ClientMessage::CreateRoom {
671 room_name: config.room_name.clone(),
672 max_players: config.max_players,
673 format: config.format.clone(),
674 protocol_version: PROTOCOL_VERSION,
675 hosted: !config.host_plays,
676 engine: engine_kind(config),
677 draft_config: None,
678 sealed_config: None,
679 official_key: config.official_key.clone(),
680 password: config.room_password.clone(),
681 reconnect_timeout_s: config.reconnect_timeout_s,
682 })
683 .await?;
684 info!(room_name = %config.room_name, "creating room");
685 wait_for_host_room(client, config, snapshot).await?
686 };
687
688 if config.host_plays {
689 seat_client(client, &config.host_deck).await?;
690 } else {
691 info!(
692 username = %config.username,
693 "hosting room without occupying a player seat"
694 );
695 }
696 Ok(room_id)
697}
698
699async fn reestablish_room(
700 client: &mut RelayClient,
701 config: &Config,
702 engine_session: &SharedEngineSession,
703 snapshot: &SharedHostSnapshot,
704) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
705 let resume = {
706 let snap = snapshot.lock().map_err(|error| error.to_string())?;
707 match (&snap.resume_token, &snap.game, &snap.room_info) {
708 (Some(token), Some(game), Some(room_info)) => {
709 Some(resume_room_request(config, token, game, room_info))
710 }
711 _ => None,
712 }
713 };
714 let game_active = engine_session
715 .lock()
716 .map(|guard| guard.is_some())
717 .unwrap_or(false);
718
719 if let (Some(request), true) = (resume, game_active) {
720 let room_id = request.room_id.clone();
721 client.send(&ClientMessage::ResumeRoom(request)).await?;
722 match wait_for_room_resumed(client, engine_session, snapshot).await? {
723 Some(room) => {
724 let (last_state, seat_states, prompts, player_order, pending_end_game) = {
725 let mut snap = snapshot.lock().map_err(|error| error.to_string())?;
726 snap.room_info = Some(room);
727 (
728 snap.last_state.clone(),
729 snap.last_state_by_slot.clone(),
730 snap.pending_prompts.clone(),
731 snap.game
732 .as_ref()
733 .map(|game| game.player_order.clone())
734 .unwrap_or_default(),
735 snap.pending_end_game.clone(),
736 )
737 };
738 if let Some(state) = last_state {
739 client
740 .send(&ClientMessage::BroadcastState {
741 state,
742 target_player: None,
743 })
744 .await?;
745 }
746 for (slot, state) in seat_states {
747 let Some(target_player) =
748 parse_player_slot(&slot).and_then(|index| player_order.get(index).cloned())
749 else {
750 warn!(slot, "cannot replay private state without a mapped player");
751 continue;
752 };
753 client
754 .send(&ClientMessage::BroadcastState {
755 state,
756 target_player: Some(target_player),
757 })
758 .await?;
759 }
760 for (slot, prompt) in prompts {
761 let Some(target_player) =
762 parse_player_slot(&slot).and_then(|index| player_order.get(index).cloned())
763 else {
764 warn!(slot, "cannot replay prompt without a mapped player");
765 continue;
766 };
767 client
768 .send(&ClientMessage::BroadcastState {
769 state: prompt,
770 target_player: Some(target_player),
771 })
772 .await?;
773 }
774 if let Some(game_id) = pending_end_game {
775 client.send(&ClientMessage::EndGame { game_id }).await?;
776 }
777 info!(room_id, "room resumed; snapshot re-broadcast");
778 return Ok(room_id);
779 }
780 None => {
781 warn!(room_id, "room resume rejected; abandoning hosted game");
782 abort_engine_session(engine_session);
783 }
784 }
785 }
786
787 establish_room(client, config, snapshot).await
788}
789
790fn engine_kind(config: &Config) -> EngineKind {
791 if matches!(config.backend, EngineBackendKind::Forge) {
792 EngineKind::Forge
793 } else {
794 EngineKind::Manabrew
795 }
796}
797
798fn resume_room_request(
799 config: &Config,
800 token: &str,
801 game: &GameStart,
802 room_info: &RoomInfo,
803) -> ResumeRoomRequest {
804 ResumeRoomRequest {
805 room_id: room_info.room_id.clone(),
806 resume_token: token.to_string(),
807 room_name: room_info.room_name.clone(),
808 max_players: room_info.max_players,
809 format: room_info.format.clone(),
810 hosted: !config.host_plays,
811 engine: engine_kind(config),
812 official_key: config.official_key.clone(),
813 password: config.room_password.clone(),
814 reconnect_timeout_s: Some(room_info.reconnect_timeout_s),
815 draft_config: room_info.draft_config.clone(),
816 sealed_config: room_info.sealed_config.clone(),
817 player_order: game.player_order.clone(),
818 player_decks: game.player_decks.clone(),
819 starting_life: game.starting_life,
820 bot_players: room_info
821 .players
822 .iter()
823 .filter(|player| player.is_bot)
824 .map(|player| player.username.clone())
825 .collect(),
826 game_id: game.game_id.clone(),
827 }
828}
829
830async fn wait_for_room_resumed(
831 client: &mut RelayClient,
832 engine_session: &SharedEngineSession,
833 snapshot: &SharedHostSnapshot,
834) -> Result<Option<RoomInfo>, Box<dyn std::error::Error + Send + Sync>> {
835 loop {
836 match client.recv().await? {
837 Some(ServerMessage::RoomResumed { room }) => {
838 if room.status == RoomStatus::Lobby {
839 abort_stale_engine_session(
840 engine_session,
841 snapshot,
842 "resumed room is no longer in game",
843 );
844 } else {
845 concede_missing_active_seats(engine_session, snapshot, &room);
846 }
847 return Ok(Some(room));
848 }
849 Some(ServerMessage::RoomUpdate { room }) => {
850 if room.status == RoomStatus::Lobby {
851 abort_stale_engine_session(
852 engine_session,
853 snapshot,
854 "room reset while reconnecting",
855 );
856 } else {
857 concede_missing_active_seats(engine_session, snapshot, &room);
858 }
859 if let Ok(mut snap) = snapshot.lock() {
860 snap.room_info = Some(room);
861 }
862 }
863 Some(ServerMessage::StateUpdate { from_player, state }) => {
864 let Ok(envelope) = serde_json::from_value::<StateEnvelope>(state.clone()) else {
865 continue;
866 };
867 match envelope {
868 StateEnvelope::Response { .. } => {
869 route_remote_response(engine_session, snapshot, &from_player, &state);
870 }
871 StateEnvelope::Directive {
872 from_player: claimed_slot,
873 directive,
874 } => route_remote_directive(
875 engine_session,
876 snapshot,
877 &from_player,
878 &claimed_slot,
879 &directive,
880 ),
881 _ => {}
882 }
883 }
884 Some(ServerMessage::Error { code, message }) => {
885 warn!(code, message, "resume rejected by relay");
886 return Ok(None);
887 }
888 Some(other) => debug!(?other, "ignored message while waiting for room resume"),
889 None => return Err("relay closed while waiting for room resume".into()),
890 }
891 }
892}
893
894fn cancel_engine(engine_session: &SharedEngineSession) {
895 if let Ok(guard) = engine_session.lock() {
896 if let Some(EngineSession::Forge { cancel, .. }) = guard.as_ref() {
897 cancel.store(true, Ordering::Relaxed);
898 }
899 }
900}
901
902fn abort_engine_session(engine_session: &SharedEngineSession) {
903 cancel_engine(engine_session);
904 clear_engine_session(engine_session);
907}
908
909fn abort_stale_engine_session(
910 engine_session: &SharedEngineSession,
911 snapshot: &SharedHostSnapshot,
912 reason: &str,
913) {
914 let stale_game_id = engine_session
915 .lock()
916 .ok()
917 .and_then(|guard| guard.as_ref().map(|s| s.game_id().to_string()));
918 let Some(stale_game_id) = stale_game_id else {
919 return;
920 };
921 warn!(game_id = %stale_game_id, reason, "aborting stale engine session");
922 abort_engine_session(engine_session);
923 clear_game_snapshot(snapshot, &stale_game_id);
924}
925
926fn clear_game_snapshot(snapshot: &SharedHostSnapshot, game_id: &str) {
927 if let Ok(mut snap) = snapshot.lock() {
928 if snap
929 .game
930 .as_ref()
931 .is_some_and(|game| game.game_id == game_id)
932 {
933 snap.game = None;
934 snap.last_state = None;
935 snap.last_state_by_slot.clear();
936 snap.pending_prompts.clear();
937 snap.pending_end_game = None;
938 }
939 }
940}
941
942fn end_game_without_humans(
948 engine_session: &SharedEngineSession,
949 snapshot: &SharedHostSnapshot,
950 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
951 room: &RoomInfo,
952) {
953 if room.players.iter().any(|seat| !seat.is_bot) {
954 return;
955 }
956 let game_id = engine_session
957 .lock()
958 .ok()
959 .and_then(|guard| guard.as_ref().map(|s| s.game_id().to_string()));
960 let Some(game_id) = game_id else {
961 return;
962 };
963 info!(game_id, "no human seats remain; ending hosted game");
964 abort_engine_session(engine_session);
965 clear_game_snapshot(snapshot, &game_id);
966 let _ = outbound_tx.send(ClientMessage::EndGame { game_id });
967}
968
969fn spawn_bots(config: &Config, decks: &[DeckSelection], room_id: &str, bot_state: &SharedBotState) {
970 stop_bots(bot_state);
971 let mut guard = match bot_state.lock() {
972 Ok(guard) => guard,
973 Err(error) => {
974 warn!(%error, "bot state lock poisoned");
975 return;
976 }
977 };
978 let open_seats = config.max_players.saturating_sub(1) as usize;
979 for (index, deck) in decks.iter().take(open_seats).enumerate() {
980 let username = if index == 0 {
981 config.bot_username.clone()
982 } else {
983 format!("{} {}", config.bot_username, index + 1)
984 };
985 let relay_url = config.relay_url.clone();
986 let bot_config = BotConfig {
987 username,
988 password: config.password.clone(),
989 room_id: room_id.to_string(),
990 room_password: config.room_password.clone(),
991 deck_name: deck.name.clone(),
992 deck: deck.deck.clone(),
993 commander_name: deck.commander_name.clone(),
994 agent: AgentKind::Simple,
995 answer_delay_ms: None,
996 };
997 let shutdown = Arc::new(tokio::sync::Notify::new());
998 let bot_shutdown = shutdown.clone();
999 let handle = tokio::spawn(async move {
1000 if let Err(error) = run_bot(relay_url, bot_config, bot_shutdown).await {
1001 error!(%error, "bot task exited");
1002 }
1003 });
1004 guard.push(BotState { handle, shutdown });
1005 }
1006}
1007
1008fn stop_bots(bot_state: &SharedBotState) {
1009 match bot_state.lock() {
1010 Ok(mut state) => {
1011 for bot in state.drain(..) {
1012 bot.shutdown.notify_one();
1013 let mut handle = bot.handle;
1014 tokio::spawn(async move {
1015 if time::timeout(BOT_STOP_TIMEOUT, &mut handle).await.is_err() {
1016 handle.abort();
1017 }
1018 });
1019 }
1020 }
1021 Err(error) => warn!(%error, "bot state lock poisoned"),
1022 }
1023}
1024
1025async fn wait_for_host_room(
1026 client: &mut RelayClient,
1027 config: &Config,
1028 snapshot: &SharedHostSnapshot,
1029) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
1030 loop {
1031 match client.recv().await? {
1032 Some(ServerMessage::RoomCreated {
1033 room_id,
1034 room_name,
1035 room,
1036 resume_token,
1037 }) => {
1038 info!(room_id, room_name, "room created");
1039 if let Ok(mut snap) = snapshot.lock() {
1040 snap.resume_token = resume_token;
1041 snap.room_info = Some(room);
1042 }
1043 return Ok(room_id);
1044 }
1045 Some(ServerMessage::RoomUpdate { room }) if room.host == config.username => {
1046 info!(room_id = %room.room_id, room_name = %room.room_name, "host room update");
1047 return Ok(room.room_id);
1048 }
1049 Some(ServerMessage::AuthResult { success, error, .. }) => {
1050 if !success {
1051 return Err(format!("authentication failed: {error:?}").into());
1052 }
1053 }
1054 Some(ServerMessage::Error { code, message }) => {
1055 return Err(format!("server error while creating room: {code}: {message}").into());
1056 }
1057 Some(other) => debug!(?other, "ignored message while waiting for room creation"),
1058 None => return Err("relay closed while waiting for room creation".into()),
1059 }
1060 }
1061}
1062
1063async fn seat_client(
1064 client: &mut RelayClient,
1065 deck: &DeckSelection,
1066) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1067 client
1068 .send(&ClientMessage::SetDeckSelection {
1069 deck_name: deck.name.clone(),
1070 deck: deck.deck.clone(),
1071 published_deck_id: None,
1072 commander_name: deck.commander_name.clone(),
1073 avatar_url: None,
1074 })
1075 .await?;
1076 client
1077 .send(&ClientMessage::SetReady { ready: true })
1078 .await?;
1079 info!(
1080 username = %client.username,
1081 deck = %deck.name,
1082 cards = deck.deck.cards.len(),
1083 "selected deck and marked ready"
1084 );
1085 Ok(())
1086}
1087
1088async fn run_client_loop(
1089 client: &mut RelayClient,
1090 config: &Config,
1091 room_id: &str,
1092 engine_session: &SharedEngineSession,
1093 snapshot: &SharedHostSnapshot,
1094 bot_state: &SharedBotState,
1095 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
1096 outbound_rx: &mut tokio_mpsc::UnboundedReceiver<ClientMessage>,
1097 cancel: &RoomCancel,
1098 bridge: Option<&Arc<ShellBridge>>,
1099 bridge_rx: &mut tokio_mpsc::UnboundedReceiver<ShellCommand>,
1100) -> LoopExit {
1101 let mut heartbeat = time::interval(Duration::from_secs(30));
1102 heartbeat.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
1103 let mut drain_tick = time::interval(Duration::from_secs(5));
1104 drain_tick.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
1105 let mut bot_usernames: HashSet<String> = HashSet::new();
1106
1107 loop {
1108 tokio::select! {
1109 _ = cancel.notified() => {
1110 info!(username = %client.username, "room host cancelled; shutting down");
1111 cancel_engine(engine_session);
1112 return LoopExit::Cancelled;
1113 }
1114 _ = drain_tick.tick() => {
1115 let idle = engine_session
1116 .lock()
1117 .map(|guard| guard.is_none())
1118 .unwrap_or(false);
1119 if draining() && idle {
1120 info!(username = %client.username, "draining and no active game; closing room");
1121 return LoopExit::Cancelled;
1122 }
1123 }
1124 _ = heartbeat.tick() => {
1125 if let Err(error) = client.broadcast_room_message(SELF_HOSTED_NODE_PROTOCOL, json!({
1126 "type": "heartbeat",
1127 "node": client.username,
1128 "capacity": config.max_games,
1129 })).await {
1130 warn!(%error, username = %client.username, "relay send failed");
1131 return LoopExit::Disconnected;
1132 }
1133 }
1134 outbound = outbound_rx.recv() => {
1135 let Some(outbound) = outbound else {
1136 warn!(username = %client.username, "outbound channel closed");
1137 return LoopExit::Cancelled;
1138 };
1139 if let ClientMessage::BroadcastState { state, target_player: Some(target) } = &outbound {
1140 if bridge.is_some_and(|shell| shell.try_send(target, state)) {
1141 continue;
1142 }
1143 }
1144 if let Err(error) = client.send(&outbound).await {
1145 warn!(%error, username = %client.username, "relay send failed");
1146 return LoopExit::Disconnected;
1147 }
1148 }
1149 command = bridge_rx.recv() => {
1150 let Some(command) = command else {
1151 warn!(username = %client.username, "shell bridge closed");
1152 return LoopExit::Cancelled;
1153 };
1154 let Some(shell) = bridge else { continue };
1155 match command {
1156 ShellCommand::Serving { seats } => shell.set_serving(seats),
1157 ShellCommand::Signal { to, payload } => {
1158 if let Err(error) = client
1159 .send(&ClientMessage::SignalPeer { to, payload })
1160 .await
1161 {
1162 warn!(%error, username = %client.username, "relay send failed");
1163 return LoopExit::Disconnected;
1164 }
1165 }
1166 ShellCommand::SeatEnvelope { from, envelope } => {
1167 route_seat_envelope(engine_session, snapshot, &from, &envelope);
1169 }
1170 }
1171 }
1172 message = client.recv() => {
1173 let message = match message {
1174 Ok(Some(message)) => message,
1175 Ok(None) => {
1176 warn!(username = %client.username, "relay websocket closed");
1177 return LoopExit::Disconnected;
1178 }
1179 Err(error) => {
1180 warn!(%error, username = %client.username, "relay receive failed");
1181 return LoopExit::Disconnected;
1182 }
1183 };
1184 if let Err(error) = handle_server_message(
1185 client,
1186 config,
1187 room_id,
1188 engine_session,
1189 snapshot,
1190 bot_state,
1191 outbound_tx,
1192 &mut bot_usernames,
1193 bridge,
1194 message,
1195 ).await {
1196 warn!(%error, username = %client.username, "relay send failed");
1197 return LoopExit::Disconnected;
1198 }
1199 }
1200 }
1201 }
1202}
1203
1204async fn handle_server_message(
1205 client: &mut RelayClient,
1206 config: &Config,
1207 room_id: &str,
1208 engine_session: &SharedEngineSession,
1209 snapshot: &SharedHostSnapshot,
1210 bot_state: &SharedBotState,
1211 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
1212 bot_usernames: &mut HashSet<String>,
1213 bridge: Option<&Arc<ShellBridge>>,
1214 message: ServerMessage,
1215) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1216 match message {
1217 ServerMessage::RoomUpdate { room } => {
1218 if let Ok(mut snap) = snapshot.lock() {
1219 snap.room_info = Some(room.clone());
1220 }
1221 *bot_usernames = room
1222 .players
1223 .iter()
1224 .filter(|p| p.is_bot)
1225 .map(|p| p.username.clone())
1226 .collect();
1227 log_room_update(&client.username, &room);
1228 if room.status == RoomStatus::Lobby {
1229 abort_stale_engine_session(engine_session, snapshot, "room reset to lobby");
1230 } else {
1231 end_game_without_humans(engine_session, snapshot, outbound_tx, &room);
1232 }
1233 maybe_auto_start_room(client, config, &room).await?;
1234 }
1235 ServerMessage::StateUpdate { from_player, state } => {
1236 if let Some(shell) = bridge {
1237 shell.note_relay_message(&from_player);
1238 }
1239 handle_state_update(
1240 client,
1241 config,
1242 room_id,
1243 engine_session,
1244 snapshot,
1245 bot_state,
1246 from_player,
1247 state,
1248 )
1249 .await?;
1250 }
1251 ServerMessage::PeerSignal { from, payload } => match bridge {
1252 Some(shell) => shell.forward_signal(&from, payload),
1253 None => debug!(from, "signalling arrived with no shell to hand it to"),
1254 },
1255 ServerMessage::ReadyStateChanged { username, ready } => {
1256 info!(username, ready, observer = %client.username, "ready changed");
1257 }
1258 ServerMessage::PlayerJoined { username, room_id } => {
1259 info!(username, room_id, observer = %client.username, "player joined");
1260 }
1261 ServerMessage::PlayerLeft { username, room_id } => {
1262 info!(username, room_id, observer = %client.username, "player left");
1263 let leaver_still_needed =
1264 active_player_usernames(snapshot).is_some_and(|active| active.contains(&username));
1265 if leaver_still_needed {
1266 if let Some(index) = seat_index_of(snapshot, &username) {
1267 info!(
1268 username,
1269 index, "player abandoned mid-game; conceding their seat"
1270 );
1271 concede_seat(engine_session, index);
1272 }
1273 }
1274 }
1275 ServerMessage::GameStarted {
1276 room_id,
1277 game_id,
1278 player_order,
1279 player_decks,
1280 starting_life,
1281 } => {
1282 info!(room_id, game_id, ?player_order, observer = %client.username, "game started");
1283 abort_stale_engine_session(engine_session, snapshot, "relay started a new game");
1284 if let Ok(mut snap) = snapshot.lock() {
1285 snap.game = Some(GameStart {
1286 game_id: game_id.clone(),
1287 player_order: player_order.clone(),
1288 player_decks: player_decks.clone(),
1289 starting_life,
1290 });
1291 snap.last_state = None;
1292 snap.last_state_by_slot.clear();
1293 snap.pending_prompts.clear();
1294 snap.pending_end_game = None;
1295 }
1296 let mut left_the_relay = Vec::new();
1297 if let Some(shell) = bridge {
1298 let seats = shell.freeze_for_game(&player_order, &[]);
1299 if !seats.is_empty() {
1300 info!(
1301 game_id,
1302 ?seats,
1303 "seats playing this game through the webview"
1304 );
1305 left_the_relay.extend(shell.transport_report(&seats));
1306 }
1307 }
1308 if !left_the_relay.is_empty() {
1309 let _ = outbound_tx.send(ClientMessage::ReportTransport {
1310 game_id: game_id.clone(),
1311 seats: left_the_relay,
1312 });
1313 }
1314 maybe_start_hosted_engine(
1315 config,
1316 engine_session,
1317 snapshot,
1318 outbound_tx,
1319 game_id,
1320 player_order,
1321 player_decks,
1322 starting_life,
1323 bot_usernames,
1324 );
1325 }
1326 ServerMessage::RoomTransport { members, .. } => {
1327 if let Some(shell) = bridge {
1328 shell.set_roster(members.iter().map(|member| member.username.clone()));
1330 }
1331 }
1332 ServerMessage::ServerShuttingDown { reconnect_in_s } => {
1333 info!(reconnect_in_s, observer = %client.username, "relay is restarting");
1334 }
1335 ServerMessage::Error { code, message } => {
1336 warn!(code, message, observer = %client.username, "server error");
1337 }
1338 other => {
1339 debug!(?other, observer = %client.username, "server message");
1340 }
1341 }
1342
1343 Ok(())
1344}
1345
1346async fn handle_state_update(
1347 client: &mut RelayClient,
1348 config: &Config,
1349 room_id: &str,
1350 engine_session: &SharedEngineSession,
1351 snapshot: &SharedHostSnapshot,
1352 bot_state: &SharedBotState,
1353 from_player: String,
1354 state: Value,
1355) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1356 let Ok(envelope) = serde_json::from_value::<StateEnvelope>(state.clone()) else {
1357 debug!(from_player, state = %state, observer = %client.username, "state update");
1358 return Ok(());
1359 };
1360
1361 match envelope {
1362 StateEnvelope::Response { .. } | StateEnvelope::Directive { .. } => {
1363 route_seat_envelope(engine_session, snapshot, &from_player, &state);
1364 Ok(())
1365 }
1366 StateEnvelope::RoomRelay {
1367 protocol,
1368 from_player: _,
1369 room_id: requested_room_id,
1370 payload,
1371 ..
1372 } => {
1373 info!(
1374 from_player,
1375 protocol,
1376 payload = %payload,
1377 observer = %client.username,
1378 "room relay message"
1379 );
1380 if protocol != SELF_HOSTED_NODE_PROTOCOL {
1381 return Ok(());
1382 }
1383 let payload_type = payload.get("type").and_then(Value::as_str);
1384 match payload_type {
1385 Some("removeBot") => {
1386 info!(observer = %client.username, "received removeBot request");
1387 stop_bots(bot_state);
1388 }
1389 Some("spawnBot") => {
1390 let effective_room_id = requested_room_id.as_deref().unwrap_or(room_id);
1391 if effective_room_id == room_id {
1392 info!(observer = %client.username, room_id, "received spawnBot request");
1393 let bot_decks = bot_decks_from_payload(config, &payload);
1394 spawn_bots(config, &bot_decks, room_id, bot_state);
1395 } else {
1396 debug!(
1397 observer = %client.username,
1398 current_room_id = room_id,
1399 requested_room_id = effective_room_id,
1400 "ignoring spawnBot request for another room"
1401 );
1402 }
1403 }
1404 _ => {}
1405 }
1406 Ok(())
1407 }
1408 _ => {
1409 debug!(from_player, state = %state, observer = %client.username, "state update");
1410 Ok(())
1411 }
1412 }
1413}
1414
1415fn bot_decks_from_payload(config: &Config, payload: &Value) -> Vec<DeckSelection> {
1416 let parsed = serde_json::from_value::<SpawnBotPayload>(payload.clone()).ok();
1417 let requested = match parsed {
1418 Some(SpawnBotPayload {
1419 decks: Some(decks), ..
1420 }) if !decks.is_empty() => decks,
1421 Some(SpawnBotPayload {
1422 deck: Some(deck), ..
1423 }) => vec![deck],
1424 _ => return vec![config.bot_deck.clone()],
1425 };
1426 requested
1427 .into_iter()
1428 .map(deck_selection_from_payload)
1429 .collect()
1430}
1431
1432fn deck_selection_from_payload(deck: SpawnBotDeckPayload) -> DeckSelection {
1433 info!(
1434 deck = %deck.deck_name,
1435 cards = deck.deck.cards.len(),
1436 commander = ?deck.commander_name,
1437 "using requested bot deck"
1438 );
1439 let commander_name = deck.commander_name.or_else(|| {
1440 deck.deck
1441 .commanders
1442 .as_ref()
1443 .and_then(|commanders| commanders.first())
1444 .map(|commander| commander.identity.name.clone())
1445 });
1446 DeckSelection {
1447 name: deck.deck_name,
1448 deck: deck.deck,
1449 commander_name,
1450 }
1451}
1452
1453async fn maybe_auto_start_room(
1454 client: &mut RelayClient,
1455 config: &Config,
1456 room: &RoomInfo,
1457) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1458 if !config.auto_start || draining() {
1459 return Ok(());
1460 }
1461 if config.format == GameFormat::Any {
1462 return Ok(());
1463 }
1464 if room.host != config.username
1465 || room.status != RoomStatus::Lobby
1466 || room.players.len() < config.max_players as usize
1467 {
1468 return Ok(());
1469 }
1470 let all_ready = room
1471 .players
1472 .iter()
1473 .all(|player| player.connected && player.ready && player.selected_deck_name.is_some());
1474 if all_ready {
1475 info!(
1476 room_id = %room.room_id,
1477 players = room.players.len(),
1478 "all players ready; auto-starting hosted game"
1479 );
1480 client
1481 .send(&ClientMessage::StartGame { format: None })
1482 .await?;
1483 }
1484 Ok(())
1485}
1486
1487const ENGINE_STACK_BYTES: usize = 64 * 1024 * 1024;
1488
1489fn spawn_engine_thread<F: FnOnce() + Send + 'static>(body: F) {
1490 if let Err(error) = thread::Builder::new()
1491 .name("hosted-engine".to_string())
1492 .stack_size(ENGINE_STACK_BYTES)
1493 .spawn(body)
1494 {
1495 error!(%error, "failed to spawn hosted engine thread");
1496 }
1497}
1498
1499fn is_commander_variant(format: GameFormat) -> bool {
1502 match format {
1503 GameFormat::Any | GameFormat::Commander | GameFormat::Brawl | GameFormat::Oathbreaker => {
1504 true
1505 }
1506 GameFormat::Standard
1507 | GameFormat::Pioneer
1508 | GameFormat::Modern
1509 | GameFormat::Legacy
1510 | GameFormat::Vintage
1511 | GameFormat::Pauper
1512 | GameFormat::Premodern
1513 | GameFormat::Draft
1514 | GameFormat::Sealed => false,
1515 }
1516}
1517
1518fn java_game_variant(format: GameFormat) -> &'static str {
1521 match format {
1522 GameFormat::Any => "",
1523 GameFormat::Commander => "Commander",
1524 GameFormat::Brawl => "Brawl",
1525 GameFormat::Oathbreaker => "Oathbreaker",
1526 GameFormat::Standard
1527 | GameFormat::Pioneer
1528 | GameFormat::Modern
1529 | GameFormat::Legacy
1530 | GameFormat::Vintage
1531 | GameFormat::Pauper
1532 | GameFormat::Premodern
1533 | GameFormat::Draft
1534 | GameFormat::Sealed => "Constructed",
1535 }
1536}
1537
1538fn maybe_start_hosted_engine(
1539 config: &Config,
1540 engine_session: &SharedEngineSession,
1541 snapshot: &SharedHostSnapshot,
1542 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
1543 game_id: String,
1544 player_order: Vec<String>,
1545 player_decks: Vec<PlayerDeckInfo>,
1546 starting_life: i32,
1547 bot_usernames: &HashSet<String>,
1548) {
1549 if !config.engine_enabled {
1550 debug!("hosted engine disabled for this node");
1551 return;
1552 }
1553 let backend = config.backend;
1554 if !backend.is_supported() {
1555 if let Err(error) = java_backend::JavaRuntimeConfig::from_env().validate() {
1556 warn!(
1557 backend = backend.label(),
1558 %error,
1559 "hosted engine backend runtime is not ready"
1560 );
1561 return;
1562 }
1563 warn!(
1564 backend = backend.label(),
1565 message = java_backend::unsupported_message(),
1566 "hosted engine backend is not implemented yet"
1567 );
1568 return;
1569 }
1570
1571 let local_player_index = if config.host_plays {
1572 match player_order
1573 .iter()
1574 .position(|name| name == &config.username)
1575 {
1576 Some(index) => Some(index),
1577 None => {
1578 warn!(
1579 username = %config.username,
1580 ?player_order,
1581 "room node is configured as a player but is not in player order; not starting engine"
1582 );
1583 return;
1584 }
1585 }
1586 } else {
1587 None
1588 };
1589
1590 let mut guard = match engine_session.lock() {
1591 Ok(guard) => guard,
1592 Err(error) => {
1593 warn!(%error, "engine session lock poisoned");
1594 return;
1595 }
1596 };
1597 if let Some(session) = guard.as_ref() {
1598 warn!(
1599 game_id,
1600 stale_game_id = session.game_id(),
1601 "engine session still present at game start; not starting engine"
1602 );
1603 return;
1604 }
1605
1606 let session_handle = engine_session.clone();
1607 let num_players = player_order.len();
1608 if num_players < 2 {
1609 warn!(num_players, "not enough players to start hosted engine");
1610 return;
1611 }
1612
1613 let mut deck_map: HashMap<String, PlayerDeckInfo> = player_decks
1614 .into_iter()
1615 .map(|deck| (deck.username.clone(), deck))
1616 .collect();
1617 let mut ordered_decks = Vec::with_capacity(num_players);
1618 let mut commander_names = Vec::with_capacity(num_players);
1619 let mut ai_player_indices = Vec::new();
1620 for (index, username) in player_order.iter().enumerate() {
1621 let Some(deck) = deck_map.remove(username) else {
1622 warn!(username, "missing deck for player; not starting engine");
1623 return;
1624 };
1625 if config.forge_ai && bot_usernames.contains(username) {
1626 ai_player_indices.push(index);
1627 }
1628 ordered_decks.push(deck.deck);
1629 commander_names.push(deck.commander_name);
1630 }
1631
1632 let player_names = player_order;
1633 let room_format = snapshot
1634 .lock()
1635 .ok()
1636 .and_then(|snap| snap.room_info.as_ref().map(|room| room.format.clone()))
1637 .unwrap_or(GameFormat::Any);
1638 let commander_variant = is_commander_variant(room_format.clone());
1639 let game_variant = java_game_variant(room_format).to_string();
1640
1641 match backend {
1642 EngineBackendKind::Manabrew => {
1643 let (remote_prompt_tx, remote_prompt_rx) = std_mpsc::channel::<(usize, AgentMessage)>();
1644 let mut remote_response_txs = HashMap::new();
1645 let mut remote_response_rxs = Vec::new();
1646 for i in 0..num_players {
1647 if Some(i) == local_player_index {
1648 continue;
1649 }
1650 let (response_tx, response_rx) = std_mpsc::channel::<ClientToServerMessage>();
1651 remote_response_txs.insert(i, response_tx);
1652 remote_response_rxs.push((i, response_rx));
1653 }
1654 let engine_clock = EngineClock::default();
1655 *guard = Some(EngineSession::Manabrew {
1656 game_id: game_id.clone(),
1657 remote_response_txs,
1658 engine_clock: engine_clock.clone(),
1659 });
1660 drop(guard);
1661
1662 spawn_remote_prompt_forwarder(
1663 outbound_tx.clone(),
1664 snapshot.clone(),
1665 engine_session.clone(),
1666 game_id.clone(),
1667 remote_prompt_rx,
1668 Some(player_names.clone()),
1669 config.state_delta,
1670 engine_clock,
1671 );
1672 let (game_over_tx, game_over_rx) = std_mpsc::channel::<HostedGameOver>();
1673 spawn_game_over_forwarder(
1674 outbound_tx.clone(),
1675 game_over_rx,
1676 engine_session.clone(),
1677 snapshot.clone(),
1678 game_id.clone(),
1679 Some(player_names.clone()),
1680 );
1681 let outbound_tx = outbound_tx.clone();
1682 let snapshot = snapshot.clone();
1683 spawn_engine_thread(move || {
1684 info!(
1685 game_id,
1686 backend = backend.label(),
1687 players = num_players,
1688 local_player_index,
1689 "starting hosted engine thread"
1690 );
1691 crate::metrics::record_engine_session_started();
1692 let started = Instant::now();
1693 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1694 rust_backend::run_hosted_engine_game(
1695 game_id.clone(),
1696 player_names,
1697 ordered_decks,
1698 commander_names,
1699 local_player_index,
1700 starting_life,
1701 remote_prompt_tx,
1702 remote_response_rxs,
1703 game_over_tx,
1704 )
1705 }));
1706 finish_hosted_engine(
1707 result,
1708 &game_id,
1709 num_players,
1710 started,
1711 &outbound_tx,
1712 &session_handle,
1713 &snapshot,
1714 );
1715 });
1716 }
1717 EngineBackendKind::Forge => {
1718 let (remote_prompt_tx, remote_prompt_rx) = std_mpsc::channel::<(usize, AgentMessage)>();
1719 let mut remote_response_txs = HashMap::new();
1720 let mut remote_response_rxs = Vec::new();
1721 for i in 0..num_players {
1722 if Some(i) == local_player_index {
1723 continue;
1724 }
1725 let (response_tx, response_rx) = std_mpsc::channel::<ClientToServerMessage>();
1726 remote_response_txs.insert(i, response_tx);
1727 remote_response_rxs.push((i, response_rx));
1728 }
1729 let cancel = Arc::new(AtomicBool::new(false));
1730 let engine_clock = EngineClock::default();
1731 *guard = Some(EngineSession::Forge {
1732 game_id: game_id.clone(),
1733 remote_response_txs,
1734 cancel: cancel.clone(),
1735 engine_clock: engine_clock.clone(),
1736 });
1737 drop(guard);
1738
1739 spawn_remote_prompt_forwarder(
1740 outbound_tx.clone(),
1741 snapshot.clone(),
1742 engine_session.clone(),
1743 game_id.clone(),
1744 remote_prompt_rx,
1745 Some(player_names.clone()),
1746 config.state_delta,
1747 engine_clock,
1748 );
1749 let (game_over_tx, game_over_rx) = std_mpsc::channel::<HostedGameOver>();
1750 spawn_game_over_forwarder(
1751 outbound_tx.clone(),
1752 game_over_rx,
1753 engine_session.clone(),
1754 snapshot.clone(),
1755 game_id.clone(),
1756 Some(player_names.clone()),
1757 );
1758 let outbound_tx = outbound_tx.clone();
1759 let snapshot = snapshot.clone();
1760 spawn_engine_thread(move || {
1761 info!(
1762 game_id,
1763 backend = backend.label(),
1764 players = num_players,
1765 local_player_index,
1766 "starting hosted engine thread"
1767 );
1768 crate::metrics::record_engine_session_started();
1769 let started = Instant::now();
1770 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1771 java_backend::run_hosted_engine_game(
1772 game_id.clone(),
1773 player_names,
1774 ordered_decks,
1775 commander_names,
1776 commander_variant,
1777 game_variant,
1778 local_player_index,
1779 ai_player_indices,
1780 starting_life,
1781 remote_prompt_tx,
1782 remote_response_rxs,
1783 game_over_tx,
1784 cancel,
1785 )
1786 }));
1787 finish_hosted_engine(
1788 result,
1789 &game_id,
1790 num_players,
1791 started,
1792 &outbound_tx,
1793 &session_handle,
1794 &snapshot,
1795 );
1796 });
1797 }
1798 }
1799}
1800
1801fn clear_engine_session(engine_session: &SharedEngineSession) {
1802 match engine_session.lock() {
1803 Ok(mut guard) => *guard = None,
1804 Err(error) => warn!(%error, "engine session lock poisoned on reset"),
1805 }
1806}
1807
1808fn active_player_usernames(snapshot: &SharedHostSnapshot) -> Option<HashSet<String>> {
1813 let snap = snapshot.lock().ok()?;
1814 let game = snap.game.as_ref()?;
1815 let mut active: HashSet<String> = game.player_order.iter().cloned().collect();
1816 let players = snap
1817 .last_state
1818 .as_ref()
1819 .and_then(|state| state.pointer("/state/gameView/players"))
1820 .and_then(Value::as_array);
1821 if let Some(players) = players {
1822 for player in players {
1823 let playing = player
1824 .get("status")
1825 .and_then(Value::as_str)
1826 .is_none_or(|status| status == "playing");
1827 if playing {
1828 continue;
1829 }
1830 let index = player
1831 .get("id")
1832 .and_then(Value::as_str)
1833 .and_then(parse_player_slot);
1834 if let Some(username) = index.and_then(|i| game.player_order.get(i)) {
1835 active.remove(username);
1836 }
1837 }
1838 }
1839 Some(active)
1840}
1841
1842fn seat_index_of(snapshot: &SharedHostSnapshot, username: &str) -> Option<usize> {
1843 let snap = snapshot.lock().ok()?;
1844 let game = snap.game.as_ref()?;
1845 game.player_order.iter().position(|name| name == username)
1846}
1847
1848fn concede_missing_active_seats(
1849 engine_session: &SharedEngineSession,
1850 snapshot: &SharedHostSnapshot,
1851 room: &RoomInfo,
1852) {
1853 let Some(active) = active_player_usernames(snapshot) else {
1854 return;
1855 };
1856 let present: HashSet<&str> = room
1857 .players
1858 .iter()
1859 .map(|player| player.username.as_str())
1860 .collect();
1861 for username in active {
1862 if !present.contains(username.as_str()) {
1863 if let Some(index) = seat_index_of(snapshot, &username) {
1864 concede_seat(engine_session, index);
1865 }
1866 }
1867 }
1868}
1869
1870fn concede_seat(engine_session: &SharedEngineSession, player_index: usize) {
1871 send_seat_message(
1872 engine_session,
1873 player_index,
1874 ClientToServerMessage::Directive {
1875 directive: manabrew_protocol::transport::DirectiveInput::Concede,
1876 },
1877 );
1878}
1879
1880fn send_seat_message(
1881 engine_session: &SharedEngineSession,
1882 player_index: usize,
1883 message: ClientToServerMessage,
1884) {
1885 let guard = match engine_session.lock() {
1886 Ok(guard) => guard,
1887 Err(error) => {
1888 warn!(%error, "engine session lock poisoned");
1889 return;
1890 }
1891 };
1892 let Some(session) = guard.as_ref() else {
1893 debug!(player_index, "no engine session for seat action");
1894 return;
1895 };
1896 let txs = match session {
1897 EngineSession::Manabrew {
1898 remote_response_txs,
1899 ..
1900 }
1901 | EngineSession::Forge {
1902 remote_response_txs,
1903 ..
1904 } => remote_response_txs,
1905 };
1906 let Some(tx) = txs.get(&player_index) else {
1907 debug!(player_index, "no response channel for player");
1908 return;
1909 };
1910 if let Err(error) = tx.send(message) {
1911 warn!(player_index, %error, "failed to route seat message");
1912 }
1913}
1914
1915fn finish_hosted_engine(
1916 result: std::thread::Result<Result<(), String>>,
1917 game_id: &str,
1918 players: usize,
1919 started: Instant,
1920 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
1921 session_handle: &SharedEngineSession,
1922 snapshot: &SharedHostSnapshot,
1923) {
1924 let fatal = match result {
1925 Ok(Ok(())) => {
1926 info!(game_id, "hosted engine thread finished");
1927 None
1928 }
1929 Ok(Err(message)) => {
1930 error!(game_id, message, "hosted engine exited with a fatal error");
1931 Some(message)
1932 }
1933 Err(panic) => {
1934 let message = if let Some(message) = panic.downcast_ref::<String>() {
1935 message.clone()
1936 } else if let Some(message) = panic.downcast_ref::<&str>() {
1937 message.to_string()
1938 } else {
1939 "the host engine panicked".to_string()
1940 };
1941 error!(game_id, message, "hosted engine thread panicked");
1942 Some(message)
1943 }
1944 };
1945 crate::metrics::record_engine_session_finished(players, started, fatal.as_deref());
1946 let still_owner = session_handle
1947 .lock()
1948 .map(|guard| guard.as_ref().is_some_and(|s| s.game_id() == game_id))
1949 .unwrap_or(false);
1950 if let Some(message) = fatal {
1951 if still_owner {
1952 if let Ok(mut snap) = snapshot.lock() {
1953 if snap
1954 .game
1955 .as_ref()
1956 .is_some_and(|game| game.game_id == game_id)
1957 {
1958 snap.pending_end_game = Some(game_id.to_string());
1959 }
1960 }
1961 if let Ok(state) = serde_json::to_value(StateEnvelope::Fatal { message }) {
1962 let _ = outbound_tx.send(ClientMessage::BroadcastState {
1963 state,
1964 target_player: None,
1965 });
1966 }
1967 let _ = outbound_tx.send(ClientMessage::EndGame {
1968 game_id: game_id.to_string(),
1969 });
1970 } else {
1971 warn!(game_id, message, "stale engine session finished with error");
1972 }
1973 }
1974}
1975
1976fn route_seat_envelope(
1978 engine_session: &SharedEngineSession,
1979 snapshot: &SharedHostSnapshot,
1980 from_player: &str,
1981 state: &Value,
1982) {
1983 match serde_json::from_value::<StateEnvelope>(state.clone()) {
1984 Ok(StateEnvelope::Response { .. }) => {
1985 route_remote_response(engine_session, snapshot, from_player, state)
1986 }
1987 Ok(StateEnvelope::Directive {
1988 from_player: claimed_slot,
1989 directive,
1990 }) => route_remote_directive(
1991 engine_session,
1992 snapshot,
1993 from_player,
1994 &claimed_slot,
1995 &directive,
1996 ),
1997 _ => debug!(from_player, state = %state, "unroutable seat envelope"),
1998 }
1999}
2000
2001fn route_remote_response(
2002 engine_session: &SharedEngineSession,
2003 snapshot: &SharedHostSnapshot,
2004 authenticated_username: &str,
2005 state: &Value,
2006) {
2007 let envelope: StateEnvelope = match serde_json::from_value(state.clone()) {
2008 Ok(envelope) => envelope,
2009 Err(error) => {
2010 warn!(%error, state = %state, "relay response invalid envelope");
2011 return;
2012 }
2013 };
2014 let StateEnvelope::Response {
2015 from_player,
2016 prompt_id,
2017 action: action_value,
2018 } = envelope
2019 else {
2020 warn!(state = %state, "expected response envelope");
2021 return;
2022 };
2023 let Some(player_index) =
2024 authenticated_player_index(snapshot, authenticated_username, &from_player, "response")
2025 else {
2026 return;
2027 };
2028 let action: PromptOutput = match serde_json::from_value(action_value) {
2029 Ok(action) => action,
2030 Err(error) => {
2031 warn!(from_player, %error, "relay response has invalid action");
2032 return;
2033 }
2034 };
2035
2036 let guard = match engine_session.lock() {
2037 Ok(guard) => guard,
2038 Err(error) => {
2039 warn!(%error, "engine session lock poisoned");
2040 return;
2041 }
2042 };
2043 let Some(session) = guard.as_ref() else {
2044 debug!(from_player, "no engine session for relay response");
2045 return;
2046 };
2047 let tx = match session {
2048 EngineSession::Manabrew {
2049 remote_response_txs,
2050 ..
2051 } => remote_response_txs.get(&player_index),
2052 EngineSession::Forge {
2053 remote_response_txs,
2054 ..
2055 } => {
2056 debug!(
2057 from_player,
2058 player_index, "routing relay response to java engine"
2059 );
2060 remote_response_txs.get(&player_index)
2061 }
2062 };
2063 let Some(tx) = tx else {
2064 debug!(from_player, player_index, "no response channel for player");
2065 return;
2066 };
2067 session.engine_clock().mark_response();
2068 if let Err(error) = tx.send(ClientToServerMessage::Response { prompt_id, action }) {
2069 warn!(from_player, %error, "failed to route relay response");
2070 return;
2071 }
2072 drop(guard);
2073 if let Ok(mut snap) = snapshot.lock() {
2074 snap.pending_prompts.remove(&from_player);
2075 }
2076}
2077
2078fn route_remote_directive(
2079 engine_session: &SharedEngineSession,
2080 snapshot: &SharedHostSnapshot,
2081 authenticated_username: &str,
2082 claimed_slot: &str,
2083 directive: &Value,
2084) {
2085 let directive: DirectiveInput = match serde_json::from_value(directive.clone()) {
2086 Ok(directive) => directive,
2087 Err(error) => {
2088 warn!(claimed_slot, %error, "relay directive is invalid");
2089 return;
2090 }
2091 };
2092 let Some(player_index) =
2093 authenticated_player_index(snapshot, authenticated_username, claimed_slot, "directive")
2094 else {
2095 return;
2096 };
2097 info!(claimed_slot, player_index, ?directive, "routing directive");
2098 match directive {
2099 DirectiveInput::Concede => concede_seat(engine_session, player_index),
2100 }
2101}
2102
2103fn authenticated_player_index(
2104 snapshot: &SharedHostSnapshot,
2105 authenticated_username: &str,
2106 claimed_slot: &str,
2107 message_kind: &str,
2108) -> Option<usize> {
2109 let Some(player_index) = seat_index_of(snapshot, authenticated_username) else {
2110 warn!(
2111 authenticated_username,
2112 message_kind, "relay sender has no engine seat"
2113 );
2114 return None;
2115 };
2116 let authenticated_slot = player_slot(player_index);
2117 if claimed_slot != authenticated_slot {
2118 warn!(
2119 authenticated_username,
2120 claimed_slot,
2121 authenticated_slot,
2122 message_kind,
2123 "relay sender claimed another engine seat"
2124 );
2125 return None;
2126 }
2127 Some(player_index)
2128}
2129
2130fn stamp_fingerprint(mut envelope: Value) -> Value {
2133 let Some(state) = envelope.get("state") else {
2134 return envelope;
2135 };
2136 let fingerprint = manabrew_relay_protocol::state_delta::fingerprint(state);
2137 if let Some(object) = envelope.as_object_mut() {
2138 object.insert("fingerprint".to_string(), Value::String(fingerprint));
2139 }
2140 envelope
2141}
2142
2143fn stamp_emit_ms(mut envelope: Value, elapsed: Duration) -> Value {
2147 if let Some(object) = envelope.as_object_mut() {
2148 object.insert(
2149 "emitMs".to_string(),
2150 Value::from(elapsed.as_millis().min(u128::from(u32::MAX)) as u32),
2151 );
2152 }
2153 envelope
2154}
2155
2156#[derive(Clone, Default)]
2164pub struct EngineClock(std::sync::Arc<std::sync::atomic::AtomicU64>);
2165
2166impl EngineClock {
2167 fn now() -> u64 {
2168 use std::sync::OnceLock;
2169 static START: OnceLock<Instant> = OnceLock::new();
2170 START.get_or_init(Instant::now).elapsed().as_millis() as u64
2171 }
2172
2173 pub fn mark_response(&self) {
2174 self.0
2175 .store(Self::now(), std::sync::atomic::Ordering::Relaxed);
2176 }
2177
2178 pub fn mark_decision_complete(&self) {
2181 self.0.store(0, std::sync::atomic::Ordering::Relaxed);
2182 }
2183
2184 pub fn elapsed_ms(&self) -> Option<u32> {
2194 let at = self.0.load(std::sync::atomic::Ordering::Relaxed);
2195 if at == 0 {
2196 return None;
2197 }
2198 let delta = Self::now().saturating_sub(at);
2199 Some(delta.min(u32::MAX as u64) as u32)
2200 }
2201}
2202
2203fn patch_against_last(
2204 bases: &mut HashMap<usize, (Value, String)>,
2205 player_index: usize,
2206 per_seat: bool,
2207 slot: &str,
2208 envelope: &Value,
2209 engine_ms: Option<u32>,
2210) -> Option<Value> {
2211 let next = envelope.get("state")?.clone();
2212 let fingerprint = envelope.get("fingerprint")?.as_str()?.to_string();
2213 let previous = bases.insert(player_index, (next.clone(), fingerprint.clone()));
2214 let (base_state, base) = previous?;
2215 let patch = manabrew_relay_protocol::state_delta::diff(&base_state, &next)?;
2216 serde_json::to_value(StateEnvelope::StateDelta {
2217 for_player: per_seat.then(|| slot.to_string()),
2218 base,
2219 fingerprint,
2220 patch,
2221 engine_ms,
2222 emit_ms: None,
2223 })
2224 .ok()
2225}
2226
2227fn spawn_remote_prompt_forwarder(
2228 outbound_tx: tokio_mpsc::UnboundedSender<ClientMessage>,
2229 snapshot: SharedHostSnapshot,
2230 engine_session: SharedEngineSession,
2231 game_id: String,
2232 remote_prompt_rx: std_mpsc::Receiver<(usize, AgentMessage)>,
2233 seat_usernames: Option<Vec<String>>,
2234 state_delta: bool,
2235 engine_clock: EngineClock,
2236) {
2237 thread::spawn(move || {
2238 let mut last_state_by_seat: HashMap<usize, Value> = HashMap::new();
2239 let mut delta_bases: HashMap<usize, (Value, String)> = HashMap::new();
2240 let mut last_state: Option<Value> = None;
2241 let mut last_display: Option<Value> = None;
2242 while let Ok((player_index, message)) = remote_prompt_rx.recv() {
2243 let Ok(session) = engine_session.lock() else {
2244 break;
2245 };
2246 if session
2247 .as_ref()
2248 .is_none_or(|session| session.game_id() != game_id)
2249 {
2250 break;
2251 }
2252 let per_seat = seat_usernames.is_some() && player_index != OBSERVER_SEAT;
2253 let slot = player_slot(player_index);
2254 let engine_ms = engine_clock.elapsed_ms();
2255 if matches!(message, AgentMessage::Prompt(_)) {
2256 engine_clock.mark_decision_complete();
2257 }
2258 let emit_started = Instant::now();
2259 let envelope = match &message {
2260 AgentMessage::State(state_update) if !per_seat => StateEnvelope::State {
2261 for_player: None,
2262 state: serde_json::to_value(state_update).unwrap_or(Value::Null),
2263 fingerprint: None,
2264 engine_ms,
2265 emit_ms: None,
2266 },
2267 _ => StateEnvelope::for_agent_message_timed(slot.clone(), &message, engine_ms),
2268 };
2269 let Ok(state) = serde_json::to_value(envelope) else {
2270 continue;
2271 };
2272 let state = match &message {
2273 AgentMessage::State(_) if state_delta => stamp_fingerprint(state),
2274 _ => state,
2275 };
2276 match &message {
2277 AgentMessage::State(_) if per_seat => {
2278 if last_state_by_seat.get(&player_index) == Some(&state) {
2279 continue;
2280 }
2281 last_state_by_seat.insert(player_index, state.clone());
2282 }
2283 AgentMessage::State(_) if last_state.as_ref() == Some(&state) => continue,
2284 AgentMessage::State(_) => last_state = Some(state.clone()),
2285 AgentMessage::Display(_) if last_display.as_ref() == Some(&state) => continue,
2286 AgentMessage::Display(_) => last_display = Some(state.clone()),
2287 AgentMessage::Prompt(_) | AgentMessage::Error(_) => {}
2288 }
2289 if let Ok(mut snap) = snapshot.lock() {
2290 match &message {
2291 AgentMessage::State(_) if per_seat => {
2292 snap.last_state_by_slot.insert(slot.clone(), state.clone());
2293 }
2294 AgentMessage::State(_) => snap.last_state = Some(state.clone()),
2295 AgentMessage::Prompt(_) => {
2296 snap.pending_prompts.insert(slot.clone(), state.clone());
2297 }
2298 AgentMessage::Display(_) | AgentMessage::Error(_) => {}
2299 }
2300 }
2301 let state = match &message {
2302 AgentMessage::State(_) if state_delta => patch_against_last(
2303 &mut delta_bases,
2304 player_index,
2305 per_seat,
2306 &slot,
2307 &state,
2308 engine_ms,
2309 )
2310 .unwrap_or(state),
2311 _ => state,
2312 };
2313 let target_player = if per_seat
2314 && matches!(
2315 message,
2316 AgentMessage::State(_) | AgentMessage::Prompt(_) | AgentMessage::Error(_)
2317 ) {
2318 let Some(target_player) = seat_usernames
2319 .as_ref()
2320 .and_then(|names| names.get(player_index).cloned())
2321 else {
2322 warn!(player_index, PRIVATE_MESSAGE_MISSING_PLAYER);
2323 continue;
2324 };
2325 Some(target_player)
2326 } else {
2327 None
2328 };
2329 if outbound_tx
2330 .send(ClientMessage::BroadcastState {
2331 state: stamp_emit_ms(state, emit_started.elapsed()),
2332 target_player,
2333 })
2334 .is_err()
2335 {
2336 break;
2337 }
2338 }
2339 });
2340}
2341
2342fn spawn_game_over_forwarder(
2343 outbound_tx: tokio_mpsc::UnboundedSender<ClientMessage>,
2344 game_over_rx: std_mpsc::Receiver<HostedGameOver>,
2345 session_handle: SharedEngineSession,
2346 snapshot: SharedHostSnapshot,
2347 game_id: String,
2348 seat_usernames: Option<Vec<String>>,
2349) {
2350 thread::spawn(move || {
2351 while let Ok(game_over) = game_over_rx.recv() {
2352 let Ok(session) = session_handle.lock() else {
2353 return;
2354 };
2355 if session
2356 .as_ref()
2357 .is_none_or(|session| session.game_id() != game_id)
2358 {
2359 warn!(
2360 game_id,
2361 "stale engine session reached game over; not ending the relay game"
2362 );
2363 continue;
2364 }
2365 if let Ok(mut snap) = snapshot.lock() {
2366 if snap
2367 .game
2368 .as_ref()
2369 .is_some_and(|game| game.game_id == game_id)
2370 {
2371 snap.pending_end_game = Some(game_id.clone());
2372 }
2373 }
2374 let mut last_state_by_seat: HashMap<usize, Value> = HashMap::new();
2375 let mut last_state: Option<Value> = None;
2376 for (player_index, message) in game_over.messages {
2377 let per_seat = seat_usernames.is_some() && player_index != OBSERVER_SEAT;
2378 let envelope = match &message {
2379 AgentMessage::State(state_update) if !per_seat => StateEnvelope::State {
2380 for_player: None,
2381 state: serde_json::to_value(state_update).unwrap_or(Value::Null),
2382 fingerprint: None,
2383 engine_ms: None,
2384 emit_ms: None,
2385 },
2386 _ => StateEnvelope::for_agent_message(player_slot(player_index), &message),
2387 };
2388 let Ok(state) = serde_json::to_value(envelope) else {
2389 continue;
2390 };
2391 match &message {
2392 AgentMessage::State(_) if per_seat => {
2393 if last_state_by_seat.get(&player_index) == Some(&state) {
2394 continue;
2395 }
2396 last_state_by_seat.insert(player_index, state.clone());
2397 }
2398 AgentMessage::State(_) if last_state.as_ref() == Some(&state) => continue,
2399 AgentMessage::State(_) => last_state = Some(state.clone()),
2400 AgentMessage::Display(_) | AgentMessage::Prompt(_) | AgentMessage::Error(_) => {
2401 }
2402 }
2403 let target_player = if per_seat
2404 && matches!(
2405 message,
2406 AgentMessage::State(_) | AgentMessage::Prompt(_) | AgentMessage::Error(_)
2407 ) {
2408 let Some(target_player) = seat_usernames
2409 .as_ref()
2410 .and_then(|names| names.get(player_index).cloned())
2411 else {
2412 warn!(player_index, PRIVATE_MESSAGE_MISSING_PLAYER);
2413 continue;
2414 };
2415 Some(target_player)
2416 } else {
2417 None
2418 };
2419 if outbound_tx
2420 .send(ClientMessage::BroadcastState {
2421 state,
2422 target_player,
2423 })
2424 .is_err()
2425 {
2426 return;
2427 }
2428 }
2429 let Ok(state) = serde_json::to_value(StateEnvelope::RoomRelay {
2430 protocol: SELF_HOSTED_NODE_PROTOCOL.to_string(),
2431 version: 1,
2432 message_id: Uuid::new_v4().to_string(),
2433 from_player: None,
2434 target_player: None,
2435 room_id: None,
2436 payload: json!({ "type": "gameOver", "gameId": game_over.game_id }),
2437 }) else {
2438 continue;
2439 };
2440 if outbound_tx
2441 .send(ClientMessage::BroadcastState {
2442 state,
2443 target_player: None,
2444 })
2445 .is_err()
2446 {
2447 return;
2448 }
2449 if outbound_tx
2450 .send(ClientMessage::EndGame {
2451 game_id: game_id.clone(),
2452 })
2453 .is_err()
2454 {
2455 return;
2456 }
2457 }
2458 });
2459}
2460
2461async fn relay_writer(mut write: WsWrite, mut outbound: tokio_mpsc::UnboundedReceiver<Message>) {
2462 while let Some(message) = outbound.recv().await {
2463 let started = Instant::now();
2464 let closing = matches!(message, Message::Close(_));
2465 if let Err(error) = write.send(message).await {
2466 warn!(%error, "relay write failed");
2467 break;
2468 }
2469 crate::metrics::record_relay_send(started.elapsed());
2470 if closing {
2471 break;
2472 }
2473 }
2474 let _ = write.close().await;
2475}
2476
2477fn self_minted_identity_token(kind: &str, handle: &str) -> String {
2478 let iat = std::time::SystemTime::now()
2479 .duration_since(std::time::UNIX_EPOCH)
2480 .map(|elapsed| elapsed.as_secs() as i64)
2481 .unwrap_or_default();
2482 identity_token::mint_unsigned(&format!("{kind}:{handle}"), handle, iat, 24 * 60 * 60)
2483}
2484
2485impl RelayClient {
2486 async fn connect(
2487 relay_url: &str,
2488 username: &str,
2489 password: &str,
2490 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
2491 info!(relay_url, username, "connecting relay client");
2492 let (socket, _) = connect_async_with_config(relay_url, None, true).await?;
2493 let (write, read) = socket.split();
2494 let (outbound, outbound_rx) = tokio_mpsc::unbounded_channel();
2495 let writer = tokio::spawn(relay_writer(write, outbound_rx));
2496 let mut client = Self {
2497 username: username.to_string(),
2498 outbound,
2499 read,
2500 writer,
2501 };
2502 client
2503 .send(&ClientMessage::Authenticate {
2504 username: username.to_string(),
2505 password: password.to_string(),
2506 service: true,
2507 identity: Some(IdentityProof {
2508 token: Some(self_minted_identity_token("node", username)),
2509 device: None,
2510 }),
2511 client_platform: ClientPlatform::Unknown,
2512 client_version: None,
2513 })
2514 .await?;
2515 client.wait_for_auth().await?;
2516 Ok(client)
2517 }
2518
2519 async fn wait_for_auth(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2520 loop {
2521 match self.recv().await? {
2522 Some(ServerMessage::AuthResult { success, error, .. }) => {
2523 if success {
2524 info!(username = %self.username, "authenticated");
2525 return Ok(());
2526 }
2527 return Err(format!("authentication failed: {error:?}").into());
2528 }
2529 Some(other) => {
2530 debug!(?other, username = %self.username, "ignored pre-auth message")
2531 }
2532 None => return Err("relay closed before authentication".into()),
2533 }
2534 }
2535 }
2536
2537 async fn send(
2538 &mut self,
2539 message: &ClientMessage,
2540 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2541 self.enqueue(Message::Text(serde_json::to_string(message)?))
2542 }
2543
2544 fn enqueue(&self, message: Message) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2545 self.outbound
2546 .send(message)
2547 .map_err(|_| "relay writer stopped".into())
2548 }
2549
2550 async fn recv(
2551 &mut self,
2552 ) -> Result<Option<ServerMessage>, Box<dyn std::error::Error + Send + Sync>> {
2553 loop {
2554 let Some(message) = self.read.next().await else {
2555 return Ok(None);
2556 };
2557 let message = message?;
2558 match message {
2559 Message::Text(text) => {
2560 return Ok(Some(serde_json::from_str(&text)?));
2561 }
2562 Message::Ping(payload) => {
2563 self.enqueue(Message::Pong(payload))?;
2564 }
2565 Message::Close(_) => return Ok(None),
2566 _ => {}
2567 }
2568 }
2569 }
2570
2571 async fn close(&mut self) {
2572 if self.enqueue(Message::Close(None)).is_err() {
2573 return;
2574 }
2575 let _ = time::timeout(CLOSE_DRAIN_TIMEOUT, async {
2576 while let Some(Ok(_)) = self.read.next().await {}
2577 })
2578 .await;
2579 self.writer.abort();
2580 }
2581
2582 async fn broadcast_room_message(
2583 &mut self,
2584 protocol: &str,
2585 payload: Value,
2586 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2587 let envelope = StateEnvelope::RoomRelay {
2588 protocol: protocol.to_string(),
2589 version: 1,
2590 message_id: Uuid::new_v4().to_string(),
2591 from_player: Some(self.username.clone()),
2592 target_player: None,
2593 room_id: None,
2594 payload,
2595 };
2596 self.send(&ClientMessage::BroadcastState {
2597 state: serde_json::to_value(envelope)?,
2598 target_player: None,
2599 })
2600 .await
2601 }
2602}
2603
2604fn log_room_update(observer: &str, room: &RoomInfo) {
2605 let players = room
2606 .players
2607 .iter()
2608 .map(|player| {
2609 format!(
2610 "{}{}{}",
2611 player.username,
2612 if player.ready { ":ready" } else { "" },
2613 if player.connected { "" } else { ":offline" },
2614 )
2615 })
2616 .collect::<Vec<_>>()
2617 .join(",");
2618 info!(
2619 observer,
2620 room_id = %room.room_id,
2621 room_name = %room.room_name,
2622 host = %room.host,
2623 players,
2624 "room update"
2625 );
2626}