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