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 })
684 .await?;
685 info!(room_name = %config.room_name, "creating room");
686 wait_for_host_room(client, config, snapshot).await?
687 };
688
689 if config.host_plays {
690 seat_client(client, &config.host_deck).await?;
691 } else {
692 info!(
693 username = %config.username,
694 "hosting room without occupying a player seat"
695 );
696 }
697 Ok(room_id)
698}
699
700async fn reestablish_room(
701 client: &mut RelayClient,
702 config: &Config,
703 engine_session: &SharedEngineSession,
704 snapshot: &SharedHostSnapshot,
705) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
706 let resume = {
707 let snap = snapshot.lock().map_err(|error| error.to_string())?;
708 match (&snap.resume_token, &snap.game, &snap.room_info) {
709 (Some(token), Some(game), Some(room_info)) => {
710 Some(resume_room_request(config, token, game, room_info))
711 }
712 _ => None,
713 }
714 };
715 let game_active = engine_session
716 .lock()
717 .map(|guard| guard.is_some())
718 .unwrap_or(false);
719
720 if let (Some(request), true) = (resume, game_active) {
721 let room_id = request.room_id.clone();
722 client.send(&ClientMessage::ResumeRoom(request)).await?;
723 match wait_for_room_resumed(client, engine_session, snapshot).await? {
724 Some(room) => {
725 let (last_state, seat_states, prompts, player_order, pending_end_game) = {
726 let mut snap = snapshot.lock().map_err(|error| error.to_string())?;
727 snap.room_info = Some(room);
728 (
729 snap.last_state.clone(),
730 snap.last_state_by_slot.clone(),
731 snap.pending_prompts.clone(),
732 snap.game
733 .as_ref()
734 .map(|game| game.player_order.clone())
735 .unwrap_or_default(),
736 snap.pending_end_game.clone(),
737 )
738 };
739 if let Some(state) = last_state {
740 client
741 .send(&ClientMessage::BroadcastState {
742 state,
743 target_player: None,
744 })
745 .await?;
746 }
747 for (slot, state) in seat_states {
748 let Some(target_player) =
749 parse_player_slot(&slot).and_then(|index| player_order.get(index).cloned())
750 else {
751 warn!(slot, "cannot replay private state without a mapped player");
752 continue;
753 };
754 client
755 .send(&ClientMessage::BroadcastState {
756 state,
757 target_player: Some(target_player),
758 })
759 .await?;
760 }
761 for (slot, prompt) in prompts {
762 let Some(target_player) =
763 parse_player_slot(&slot).and_then(|index| player_order.get(index).cloned())
764 else {
765 warn!(slot, "cannot replay prompt without a mapped player");
766 continue;
767 };
768 client
769 .send(&ClientMessage::BroadcastState {
770 state: prompt,
771 target_player: Some(target_player),
772 })
773 .await?;
774 }
775 if let Some(game_id) = pending_end_game {
776 client.send(&ClientMessage::EndGame { game_id }).await?;
777 }
778 info!(room_id, "room resumed; snapshot re-broadcast");
779 return Ok(room_id);
780 }
781 None => {
782 warn!(room_id, "room resume rejected; abandoning hosted game");
783 abort_engine_session(engine_session);
784 }
785 }
786 }
787
788 establish_room(client, config, snapshot).await
789}
790
791fn engine_kind(config: &Config) -> EngineKind {
792 if matches!(config.backend, EngineBackendKind::Forge) {
793 EngineKind::Forge
794 } else {
795 EngineKind::Manabrew
796 }
797}
798
799fn resume_room_request(
800 config: &Config,
801 token: &str,
802 game: &GameStart,
803 room_info: &RoomInfo,
804) -> ResumeRoomRequest {
805 ResumeRoomRequest {
806 room_id: room_info.room_id.clone(),
807 resume_token: token.to_string(),
808 room_name: room_info.room_name.clone(),
809 max_players: room_info.max_players,
810 format: room_info.format.clone(),
811 hosted: !config.host_plays,
812 engine: engine_kind(config),
813 official_key: config.official_key.clone(),
814 password: config.room_password.clone(),
815 reconnect_timeout_s: Some(room_info.reconnect_timeout_s),
816 draft_config: room_info.draft_config.clone(),
817 sealed_config: room_info.sealed_config.clone(),
818 player_order: game.player_order.clone(),
819 player_decks: game.player_decks.clone(),
820 starting_life: game.starting_life,
821 bot_players: room_info
822 .players
823 .iter()
824 .filter(|player| player.is_bot)
825 .map(|player| player.username.clone())
826 .collect(),
827 game_id: game.game_id.clone(),
828 }
829}
830
831async fn wait_for_room_resumed(
832 client: &mut RelayClient,
833 engine_session: &SharedEngineSession,
834 snapshot: &SharedHostSnapshot,
835) -> Result<Option<RoomInfo>, Box<dyn std::error::Error + Send + Sync>> {
836 loop {
837 match client.recv().await? {
838 Some(ServerMessage::RoomResumed { room }) => {
839 if room.status == RoomStatus::Lobby {
840 abort_stale_engine_session(
841 engine_session,
842 snapshot,
843 "resumed room is no longer in game",
844 );
845 } else {
846 concede_missing_active_seats(engine_session, snapshot, &room);
847 }
848 return Ok(Some(room));
849 }
850 Some(ServerMessage::RoomUpdate { room }) => {
851 if room.status == RoomStatus::Lobby {
852 abort_stale_engine_session(
853 engine_session,
854 snapshot,
855 "room reset while reconnecting",
856 );
857 } else {
858 concede_missing_active_seats(engine_session, snapshot, &room);
859 }
860 if let Ok(mut snap) = snapshot.lock() {
861 snap.room_info = Some(room);
862 }
863 }
864 Some(ServerMessage::StateUpdate { from_player, state }) => {
865 let Ok(envelope) = serde_json::from_value::<StateEnvelope>(state.clone()) else {
866 continue;
867 };
868 match envelope {
869 StateEnvelope::Response { .. } => {
870 route_remote_response(engine_session, snapshot, &from_player, &state);
871 }
872 StateEnvelope::Directive {
873 from_player: claimed_slot,
874 directive,
875 } => route_remote_directive(
876 engine_session,
877 snapshot,
878 &from_player,
879 &claimed_slot,
880 &directive,
881 ),
882 _ => {}
883 }
884 }
885 Some(ServerMessage::Error { code, message }) => {
886 warn!(code, message, "resume rejected by relay");
887 return Ok(None);
888 }
889 Some(other) => debug!(?other, "ignored message while waiting for room resume"),
890 None => return Err("relay closed while waiting for room resume".into()),
891 }
892 }
893}
894
895fn cancel_engine(engine_session: &SharedEngineSession) {
896 if let Ok(guard) = engine_session.lock() {
897 if let Some(EngineSession::Forge { cancel, .. }) = guard.as_ref() {
898 cancel.store(true, Ordering::Relaxed);
899 }
900 }
901}
902
903fn abort_engine_session(engine_session: &SharedEngineSession) {
904 cancel_engine(engine_session);
905 clear_engine_session(engine_session);
908}
909
910fn abort_stale_engine_session(
911 engine_session: &SharedEngineSession,
912 snapshot: &SharedHostSnapshot,
913 reason: &str,
914) {
915 let stale_game_id = engine_session
916 .lock()
917 .ok()
918 .and_then(|guard| guard.as_ref().map(|s| s.game_id().to_string()));
919 let Some(stale_game_id) = stale_game_id else {
920 return;
921 };
922 warn!(game_id = %stale_game_id, reason, "aborting stale engine session");
923 abort_engine_session(engine_session);
924 clear_game_snapshot(snapshot, &stale_game_id);
925}
926
927fn clear_game_snapshot(snapshot: &SharedHostSnapshot, game_id: &str) {
928 if let Ok(mut snap) = snapshot.lock() {
929 if snap
930 .game
931 .as_ref()
932 .is_some_and(|game| game.game_id == game_id)
933 {
934 snap.game = None;
935 snap.last_state = None;
936 snap.last_state_by_slot.clear();
937 snap.pending_prompts.clear();
938 snap.pending_end_game = None;
939 }
940 }
941}
942
943fn end_game_without_humans(
949 engine_session: &SharedEngineSession,
950 snapshot: &SharedHostSnapshot,
951 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
952 room: &RoomInfo,
953) {
954 if room.players.iter().any(|seat| !seat.is_bot) {
955 return;
956 }
957 let game_id = engine_session
958 .lock()
959 .ok()
960 .and_then(|guard| guard.as_ref().map(|s| s.game_id().to_string()));
961 let Some(game_id) = game_id else {
962 return;
963 };
964 info!(game_id, "no human seats remain; ending hosted game");
965 abort_engine_session(engine_session);
966 clear_game_snapshot(snapshot, &game_id);
967 let _ = outbound_tx.send(ClientMessage::EndGame { game_id });
968}
969
970fn spawn_bots(config: &Config, decks: &[DeckSelection], room_id: &str, bot_state: &SharedBotState) {
971 stop_bots(bot_state);
972 let mut guard = match bot_state.lock() {
973 Ok(guard) => guard,
974 Err(error) => {
975 warn!(%error, "bot state lock poisoned");
976 return;
977 }
978 };
979 let open_seats = config.max_players.saturating_sub(1) as usize;
980 for (index, deck) in decks.iter().take(open_seats).enumerate() {
981 let username = if index == 0 {
982 config.bot_username.clone()
983 } else {
984 format!("{} {}", config.bot_username, index + 1)
985 };
986 let relay_url = config.relay_url.clone();
987 let bot_config = BotConfig {
988 username,
989 password: config.password.clone(),
990 room_id: room_id.to_string(),
991 room_password: config.room_password.clone(),
992 deck_name: deck.name.clone(),
993 deck: deck.deck.clone(),
994 commander_name: deck.commander_name.clone(),
995 agent: AgentKind::Simple,
996 answer_delay_ms: None,
997 };
998 let shutdown = Arc::new(tokio::sync::Notify::new());
999 let bot_shutdown = shutdown.clone();
1000 let handle = tokio::spawn(async move {
1001 if let Err(error) = run_bot(relay_url, bot_config, bot_shutdown).await {
1002 error!(%error, "bot task exited");
1003 }
1004 });
1005 guard.push(BotState { handle, shutdown });
1006 }
1007}
1008
1009fn stop_bots(bot_state: &SharedBotState) {
1010 match bot_state.lock() {
1011 Ok(mut state) => {
1012 for bot in state.drain(..) {
1013 bot.shutdown.notify_one();
1014 let mut handle = bot.handle;
1015 tokio::spawn(async move {
1016 if time::timeout(BOT_STOP_TIMEOUT, &mut handle).await.is_err() {
1017 handle.abort();
1018 }
1019 });
1020 }
1021 }
1022 Err(error) => warn!(%error, "bot state lock poisoned"),
1023 }
1024}
1025
1026async fn wait_for_host_room(
1027 client: &mut RelayClient,
1028 config: &Config,
1029 snapshot: &SharedHostSnapshot,
1030) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
1031 loop {
1032 match client.recv().await? {
1033 Some(ServerMessage::RoomCreated {
1034 room_id,
1035 room_name,
1036 room,
1037 resume_token,
1038 }) => {
1039 info!(room_id, room_name, "room created");
1040 if let Ok(mut snap) = snapshot.lock() {
1041 snap.resume_token = resume_token;
1042 snap.room_info = Some(room);
1043 }
1044 return Ok(room_id);
1045 }
1046 Some(ServerMessage::RoomUpdate { room }) if room.host == config.username => {
1047 info!(room_id = %room.room_id, room_name = %room.room_name, "host room update");
1048 return Ok(room.room_id);
1049 }
1050 Some(ServerMessage::AuthResult { success, error, .. }) => {
1051 if !success {
1052 return Err(format!("authentication failed: {error:?}").into());
1053 }
1054 }
1055 Some(ServerMessage::Error { code, message }) => {
1056 return Err(format!("server error while creating room: {code}: {message}").into());
1057 }
1058 Some(other) => debug!(?other, "ignored message while waiting for room creation"),
1059 None => return Err("relay closed while waiting for room creation".into()),
1060 }
1061 }
1062}
1063
1064async fn seat_client(
1065 client: &mut RelayClient,
1066 deck: &DeckSelection,
1067) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1068 client
1069 .send(&ClientMessage::SetDeckSelection {
1070 deck_name: deck.name.clone(),
1071 deck: deck.deck.clone(),
1072 published_deck_id: None,
1073 commander_name: deck.commander_name.clone(),
1074 avatar_url: None,
1075 })
1076 .await?;
1077 client
1078 .send(&ClientMessage::SetReady { ready: true })
1079 .await?;
1080 info!(
1081 username = %client.username,
1082 deck = %deck.name,
1083 cards = deck.deck.cards.len(),
1084 "selected deck and marked ready"
1085 );
1086 Ok(())
1087}
1088
1089async fn run_client_loop(
1090 client: &mut RelayClient,
1091 config: &Config,
1092 room_id: &str,
1093 engine_session: &SharedEngineSession,
1094 snapshot: &SharedHostSnapshot,
1095 bot_state: &SharedBotState,
1096 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
1097 outbound_rx: &mut tokio_mpsc::UnboundedReceiver<ClientMessage>,
1098 cancel: &RoomCancel,
1099 bridge: Option<&Arc<ShellBridge>>,
1100 bridge_rx: &mut tokio_mpsc::UnboundedReceiver<ShellCommand>,
1101) -> LoopExit {
1102 let mut heartbeat = time::interval(Duration::from_secs(30));
1103 heartbeat.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
1104 let mut drain_tick = time::interval(Duration::from_secs(5));
1105 drain_tick.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
1106 let mut bot_usernames: HashSet<String> = HashSet::new();
1107
1108 loop {
1109 tokio::select! {
1110 _ = cancel.notified() => {
1111 info!(username = %client.username, "room host cancelled; shutting down");
1112 cancel_engine(engine_session);
1113 return LoopExit::Cancelled;
1114 }
1115 _ = drain_tick.tick() => {
1116 let idle = engine_session
1117 .lock()
1118 .map(|guard| guard.is_none())
1119 .unwrap_or(false);
1120 if draining() && idle {
1121 info!(username = %client.username, "draining and no active game; closing room");
1122 return LoopExit::Cancelled;
1123 }
1124 }
1125 _ = heartbeat.tick() => {
1126 if let Err(error) = client.broadcast_room_message(SELF_HOSTED_NODE_PROTOCOL, json!({
1127 "type": "heartbeat",
1128 "node": client.username,
1129 "capacity": config.max_games,
1130 })).await {
1131 warn!(%error, username = %client.username, "relay send failed");
1132 return LoopExit::Disconnected;
1133 }
1134 }
1135 outbound = outbound_rx.recv() => {
1136 let Some(outbound) = outbound else {
1137 warn!(username = %client.username, "outbound channel closed");
1138 return LoopExit::Cancelled;
1139 };
1140 if let ClientMessage::BroadcastState { state, target_player: Some(target) } = &outbound {
1141 if bridge.is_some_and(|shell| shell.try_send(target, state)) {
1142 continue;
1143 }
1144 }
1145 if let Err(error) = client.send(&outbound).await {
1146 warn!(%error, username = %client.username, "relay send failed");
1147 return LoopExit::Disconnected;
1148 }
1149 }
1150 command = bridge_rx.recv() => {
1151 let Some(command) = command else {
1152 warn!(username = %client.username, "shell bridge closed");
1153 return LoopExit::Cancelled;
1154 };
1155 let Some(shell) = bridge else { continue };
1156 match command {
1157 ShellCommand::Serving { seats } => shell.set_serving(seats),
1158 ShellCommand::Signal { to, payload } => {
1159 if let Err(error) = client
1160 .send(&ClientMessage::SignalPeer { to, payload })
1161 .await
1162 {
1163 warn!(%error, username = %client.username, "relay send failed");
1164 return LoopExit::Disconnected;
1165 }
1166 }
1167 ShellCommand::SeatEnvelope { from, envelope } => {
1168 route_seat_envelope(engine_session, snapshot, &from, &envelope);
1170 }
1171 }
1172 }
1173 message = client.recv() => {
1174 let message = match message {
1175 Ok(Some(message)) => message,
1176 Ok(None) => {
1177 warn!(username = %client.username, "relay websocket closed");
1178 return LoopExit::Disconnected;
1179 }
1180 Err(error) => {
1181 warn!(%error, username = %client.username, "relay receive failed");
1182 return LoopExit::Disconnected;
1183 }
1184 };
1185 if let Err(error) = handle_server_message(
1186 client,
1187 config,
1188 room_id,
1189 engine_session,
1190 snapshot,
1191 bot_state,
1192 outbound_tx,
1193 &mut bot_usernames,
1194 bridge,
1195 message,
1196 ).await {
1197 warn!(%error, username = %client.username, "relay send failed");
1198 return LoopExit::Disconnected;
1199 }
1200 }
1201 }
1202 }
1203}
1204
1205async fn handle_server_message(
1206 client: &mut RelayClient,
1207 config: &Config,
1208 room_id: &str,
1209 engine_session: &SharedEngineSession,
1210 snapshot: &SharedHostSnapshot,
1211 bot_state: &SharedBotState,
1212 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
1213 bot_usernames: &mut HashSet<String>,
1214 bridge: Option<&Arc<ShellBridge>>,
1215 message: ServerMessage,
1216) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1217 match message {
1218 ServerMessage::RoomUpdate { room } => {
1219 if let Ok(mut snap) = snapshot.lock() {
1220 snap.room_info = Some(room.clone());
1221 }
1222 *bot_usernames = room
1223 .players
1224 .iter()
1225 .filter(|p| p.is_bot)
1226 .map(|p| p.username.clone())
1227 .collect();
1228 log_room_update(&client.username, &room);
1229 if room.status == RoomStatus::Lobby {
1230 abort_stale_engine_session(engine_session, snapshot, "room reset to lobby");
1231 } else {
1232 end_game_without_humans(engine_session, snapshot, outbound_tx, &room);
1233 }
1234 maybe_auto_start_room(client, config, &room).await?;
1235 }
1236 ServerMessage::StateUpdate { from_player, state } => {
1237 if let Some(shell) = bridge {
1238 shell.note_relay_message(&from_player);
1239 }
1240 handle_state_update(
1241 client,
1242 config,
1243 room_id,
1244 engine_session,
1245 snapshot,
1246 bot_state,
1247 from_player,
1248 state,
1249 )
1250 .await?;
1251 }
1252 ServerMessage::PeerSignal { from, payload } => match bridge {
1253 Some(shell) => shell.forward_signal(&from, payload),
1254 None => debug!(from, "signalling arrived with no shell to hand it to"),
1255 },
1256 ServerMessage::ReadyStateChanged { username, ready } => {
1257 info!(username, ready, observer = %client.username, "ready changed");
1258 }
1259 ServerMessage::PlayerJoined { username, room_id } => {
1260 info!(username, room_id, observer = %client.username, "player joined");
1261 }
1262 ServerMessage::PlayerLeft { username, room_id } => {
1263 info!(username, room_id, observer = %client.username, "player left");
1264 let leaver_still_needed =
1265 active_player_usernames(snapshot).is_some_and(|active| active.contains(&username));
1266 if leaver_still_needed {
1267 if let Some(index) = seat_index_of(snapshot, &username) {
1268 info!(
1269 username,
1270 index, "player abandoned mid-game; conceding their seat"
1271 );
1272 concede_seat(engine_session, index);
1273 }
1274 }
1275 }
1276 ServerMessage::GameStarted {
1277 room_id,
1278 game_id,
1279 player_order,
1280 player_decks,
1281 starting_life,
1282 } => {
1283 info!(room_id, game_id, ?player_order, observer = %client.username, "game started");
1284 abort_stale_engine_session(engine_session, snapshot, "relay started a new game");
1285 if let Ok(mut snap) = snapshot.lock() {
1286 snap.game = Some(GameStart {
1287 game_id: game_id.clone(),
1288 player_order: player_order.clone(),
1289 player_decks: player_decks.clone(),
1290 starting_life,
1291 });
1292 snap.last_state = None;
1293 snap.last_state_by_slot.clear();
1294 snap.pending_prompts.clear();
1295 snap.pending_end_game = None;
1296 }
1297 let mut left_the_relay = Vec::new();
1298 if let Some(shell) = bridge {
1299 let seats = shell.freeze_for_game(&player_order, &[]);
1300 if !seats.is_empty() {
1301 info!(
1302 game_id,
1303 ?seats,
1304 "seats playing this game through the webview"
1305 );
1306 left_the_relay.extend(shell.transport_report(&seats));
1307 }
1308 }
1309 if !left_the_relay.is_empty() {
1310 let _ = outbound_tx.send(ClientMessage::ReportTransport {
1311 game_id: game_id.clone(),
1312 seats: left_the_relay,
1313 });
1314 }
1315 maybe_start_hosted_engine(
1316 config,
1317 engine_session,
1318 snapshot,
1319 outbound_tx,
1320 game_id,
1321 player_order,
1322 player_decks,
1323 starting_life,
1324 bot_usernames,
1325 );
1326 }
1327 ServerMessage::RoomTransport { members, .. } => {
1328 if let Some(shell) = bridge {
1329 shell.set_roster(members.iter().map(|member| member.username.clone()));
1331 }
1332 }
1333 ServerMessage::ServerShuttingDown { reconnect_in_s } => {
1334 info!(reconnect_in_s, observer = %client.username, "relay is restarting");
1335 }
1336 ServerMessage::Error { code, message } => {
1337 warn!(code, message, observer = %client.username, "server error");
1338 }
1339 other => {
1340 debug!(?other, observer = %client.username, "server message");
1341 }
1342 }
1343
1344 Ok(())
1345}
1346
1347async fn handle_state_update(
1348 client: &mut RelayClient,
1349 config: &Config,
1350 room_id: &str,
1351 engine_session: &SharedEngineSession,
1352 snapshot: &SharedHostSnapshot,
1353 bot_state: &SharedBotState,
1354 from_player: String,
1355 state: Value,
1356) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1357 let Ok(envelope) = serde_json::from_value::<StateEnvelope>(state.clone()) else {
1358 debug!(from_player, state = %state, observer = %client.username, "state update");
1359 return Ok(());
1360 };
1361
1362 match envelope {
1363 StateEnvelope::Response { .. } | StateEnvelope::Directive { .. } => {
1364 route_seat_envelope(engine_session, snapshot, &from_player, &state);
1365 Ok(())
1366 }
1367 StateEnvelope::RoomRelay {
1368 protocol,
1369 from_player: _,
1370 room_id: requested_room_id,
1371 payload,
1372 ..
1373 } => {
1374 info!(
1375 from_player,
1376 protocol,
1377 payload = %payload,
1378 observer = %client.username,
1379 "room relay message"
1380 );
1381 if protocol != SELF_HOSTED_NODE_PROTOCOL {
1382 return Ok(());
1383 }
1384 let payload_type = payload.get("type").and_then(Value::as_str);
1385 match payload_type {
1386 Some("removeBot") => {
1387 info!(observer = %client.username, "received removeBot request");
1388 stop_bots(bot_state);
1389 }
1390 Some("spawnBot") => {
1391 let effective_room_id = requested_room_id.as_deref().unwrap_or(room_id);
1392 if effective_room_id == room_id {
1393 info!(observer = %client.username, room_id, "received spawnBot request");
1394 let bot_decks = bot_decks_from_payload(config, &payload);
1395 spawn_bots(config, &bot_decks, room_id, bot_state);
1396 } else {
1397 debug!(
1398 observer = %client.username,
1399 current_room_id = room_id,
1400 requested_room_id = effective_room_id,
1401 "ignoring spawnBot request for another room"
1402 );
1403 }
1404 }
1405 _ => {}
1406 }
1407 Ok(())
1408 }
1409 _ => {
1410 debug!(from_player, state = %state, observer = %client.username, "state update");
1411 Ok(())
1412 }
1413 }
1414}
1415
1416fn bot_decks_from_payload(config: &Config, payload: &Value) -> Vec<DeckSelection> {
1417 let parsed = serde_json::from_value::<SpawnBotPayload>(payload.clone()).ok();
1418 let requested = match parsed {
1419 Some(SpawnBotPayload {
1420 decks: Some(decks), ..
1421 }) if !decks.is_empty() => decks,
1422 Some(SpawnBotPayload {
1423 deck: Some(deck), ..
1424 }) => vec![deck],
1425 _ => return vec![config.bot_deck.clone()],
1426 };
1427 requested
1428 .into_iter()
1429 .map(deck_selection_from_payload)
1430 .collect()
1431}
1432
1433fn deck_selection_from_payload(deck: SpawnBotDeckPayload) -> DeckSelection {
1434 info!(
1435 deck = %deck.deck_name,
1436 cards = deck.deck.cards.len(),
1437 commander = ?deck.commander_name,
1438 "using requested bot deck"
1439 );
1440 let commander_name = deck.commander_name.or_else(|| {
1441 deck.deck
1442 .commanders
1443 .as_ref()
1444 .and_then(|commanders| commanders.first())
1445 .map(|commander| commander.identity.name.clone())
1446 });
1447 DeckSelection {
1448 name: deck.deck_name,
1449 deck: deck.deck,
1450 commander_name,
1451 }
1452}
1453
1454async fn maybe_auto_start_room(
1455 client: &mut RelayClient,
1456 config: &Config,
1457 room: &RoomInfo,
1458) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1459 if !config.auto_start || draining() {
1460 return Ok(());
1461 }
1462 if config.format == GameFormat::Any {
1463 return Ok(());
1464 }
1465 if room.host != config.username
1466 || room.status != RoomStatus::Lobby
1467 || room.players.len() < config.max_players as usize
1468 {
1469 return Ok(());
1470 }
1471 let all_ready = room
1472 .players
1473 .iter()
1474 .all(|player| player.connected && player.ready && player.selected_deck_name.is_some());
1475 if all_ready {
1476 info!(
1477 room_id = %room.room_id,
1478 players = room.players.len(),
1479 "all players ready; auto-starting hosted game"
1480 );
1481 client
1482 .send(&ClientMessage::StartGame { format: None })
1483 .await?;
1484 }
1485 Ok(())
1486}
1487
1488const ENGINE_STACK_BYTES: usize = 64 * 1024 * 1024;
1489
1490fn spawn_engine_thread<F: FnOnce() + Send + 'static>(body: F) {
1491 if let Err(error) = thread::Builder::new()
1492 .name("hosted-engine".to_string())
1493 .stack_size(ENGINE_STACK_BYTES)
1494 .spawn(body)
1495 {
1496 error!(%error, "failed to spawn hosted engine thread");
1497 }
1498}
1499
1500fn is_commander_variant(format: GameFormat) -> bool {
1503 match format {
1504 GameFormat::Any | GameFormat::Commander | GameFormat::Brawl | GameFormat::Oathbreaker => {
1505 true
1506 }
1507 GameFormat::Standard
1508 | GameFormat::Pioneer
1509 | GameFormat::Modern
1510 | GameFormat::Legacy
1511 | GameFormat::Vintage
1512 | GameFormat::Pauper
1513 | GameFormat::Premodern
1514 | GameFormat::Draft
1515 | GameFormat::Sealed => false,
1516 }
1517}
1518
1519fn java_game_variant(format: GameFormat) -> &'static str {
1522 match format {
1523 GameFormat::Any => "",
1524 GameFormat::Commander => "Commander",
1525 GameFormat::Brawl => "Brawl",
1526 GameFormat::Oathbreaker => "Oathbreaker",
1527 GameFormat::Standard
1528 | GameFormat::Pioneer
1529 | GameFormat::Modern
1530 | GameFormat::Legacy
1531 | GameFormat::Vintage
1532 | GameFormat::Pauper
1533 | GameFormat::Premodern
1534 | GameFormat::Draft
1535 | GameFormat::Sealed => "Constructed",
1536 }
1537}
1538
1539fn maybe_start_hosted_engine(
1540 config: &Config,
1541 engine_session: &SharedEngineSession,
1542 snapshot: &SharedHostSnapshot,
1543 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
1544 game_id: String,
1545 player_order: Vec<String>,
1546 player_decks: Vec<PlayerDeckInfo>,
1547 starting_life: i32,
1548 bot_usernames: &HashSet<String>,
1549) {
1550 if !config.engine_enabled {
1551 debug!("hosted engine disabled for this node");
1552 return;
1553 }
1554 let backend = config.backend;
1555 if !backend.is_supported() {
1556 if let Err(error) = java_backend::JavaRuntimeConfig::from_env().validate() {
1557 warn!(
1558 backend = backend.label(),
1559 %error,
1560 "hosted engine backend runtime is not ready"
1561 );
1562 return;
1563 }
1564 warn!(
1565 backend = backend.label(),
1566 message = java_backend::unsupported_message(),
1567 "hosted engine backend is not implemented yet"
1568 );
1569 return;
1570 }
1571
1572 let local_player_index = if config.host_plays {
1573 match player_order
1574 .iter()
1575 .position(|name| name == &config.username)
1576 {
1577 Some(index) => Some(index),
1578 None => {
1579 warn!(
1580 username = %config.username,
1581 ?player_order,
1582 "room node is configured as a player but is not in player order; not starting engine"
1583 );
1584 return;
1585 }
1586 }
1587 } else {
1588 None
1589 };
1590
1591 let mut guard = match engine_session.lock() {
1592 Ok(guard) => guard,
1593 Err(error) => {
1594 warn!(%error, "engine session lock poisoned");
1595 return;
1596 }
1597 };
1598 if let Some(session) = guard.as_ref() {
1599 warn!(
1600 game_id,
1601 stale_game_id = session.game_id(),
1602 "engine session still present at game start; not starting engine"
1603 );
1604 return;
1605 }
1606
1607 let session_handle = engine_session.clone();
1608 let num_players = player_order.len();
1609 if num_players < 2 {
1610 warn!(num_players, "not enough players to start hosted engine");
1611 return;
1612 }
1613
1614 let mut deck_map: HashMap<String, PlayerDeckInfo> = player_decks
1615 .into_iter()
1616 .map(|deck| (deck.username.clone(), deck))
1617 .collect();
1618 let mut ordered_decks = Vec::with_capacity(num_players);
1619 let mut commander_names = Vec::with_capacity(num_players);
1620 let mut ai_player_indices = Vec::new();
1621 for (index, username) in player_order.iter().enumerate() {
1622 let Some(deck) = deck_map.remove(username) else {
1623 warn!(username, "missing deck for player; not starting engine");
1624 return;
1625 };
1626 if config.forge_ai && bot_usernames.contains(username) {
1627 ai_player_indices.push(index);
1628 }
1629 ordered_decks.push(deck.deck);
1630 commander_names.push(deck.commander_name);
1631 }
1632
1633 let player_names = player_order;
1634 let room_format = snapshot
1635 .lock()
1636 .ok()
1637 .and_then(|snap| snap.room_info.as_ref().map(|room| room.format.clone()))
1638 .unwrap_or(GameFormat::Any);
1639 let commander_variant = is_commander_variant(room_format.clone());
1640 let game_variant = java_game_variant(room_format).to_string();
1641
1642 match backend {
1643 EngineBackendKind::Manabrew => {
1644 let (remote_prompt_tx, remote_prompt_rx) = std_mpsc::channel::<(usize, AgentMessage)>();
1645 let mut remote_response_txs = HashMap::new();
1646 let mut remote_response_rxs = Vec::new();
1647 for i in 0..num_players {
1648 if Some(i) == local_player_index {
1649 continue;
1650 }
1651 let (response_tx, response_rx) = std_mpsc::channel::<ClientToServerMessage>();
1652 remote_response_txs.insert(i, response_tx);
1653 remote_response_rxs.push((i, response_rx));
1654 }
1655 let engine_clock = EngineClock::default();
1656 *guard = Some(EngineSession::Manabrew {
1657 game_id: game_id.clone(),
1658 remote_response_txs,
1659 engine_clock: engine_clock.clone(),
1660 });
1661 drop(guard);
1662
1663 spawn_remote_prompt_forwarder(
1664 outbound_tx.clone(),
1665 snapshot.clone(),
1666 engine_session.clone(),
1667 game_id.clone(),
1668 remote_prompt_rx,
1669 Some(player_names.clone()),
1670 config.state_delta,
1671 engine_clock,
1672 );
1673 let (game_over_tx, game_over_rx) = std_mpsc::channel::<HostedGameOver>();
1674 spawn_game_over_forwarder(
1675 outbound_tx.clone(),
1676 game_over_rx,
1677 engine_session.clone(),
1678 snapshot.clone(),
1679 game_id.clone(),
1680 Some(player_names.clone()),
1681 );
1682 let outbound_tx = outbound_tx.clone();
1683 let snapshot = snapshot.clone();
1684 spawn_engine_thread(move || {
1685 info!(
1686 game_id,
1687 backend = backend.label(),
1688 players = num_players,
1689 local_player_index,
1690 "starting hosted engine thread"
1691 );
1692 crate::metrics::record_engine_session_started();
1693 let started = Instant::now();
1694 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1695 rust_backend::run_hosted_engine_game(
1696 game_id.clone(),
1697 player_names,
1698 ordered_decks,
1699 commander_names,
1700 local_player_index,
1701 starting_life,
1702 remote_prompt_tx,
1703 remote_response_rxs,
1704 game_over_tx,
1705 )
1706 }));
1707 finish_hosted_engine(
1708 result,
1709 &game_id,
1710 num_players,
1711 started,
1712 &outbound_tx,
1713 &session_handle,
1714 &snapshot,
1715 );
1716 });
1717 }
1718 EngineBackendKind::Forge => {
1719 let (remote_prompt_tx, remote_prompt_rx) = std_mpsc::channel::<(usize, AgentMessage)>();
1720 let mut remote_response_txs = HashMap::new();
1721 let mut remote_response_rxs = Vec::new();
1722 for i in 0..num_players {
1723 if Some(i) == local_player_index {
1724 continue;
1725 }
1726 let (response_tx, response_rx) = std_mpsc::channel::<ClientToServerMessage>();
1727 remote_response_txs.insert(i, response_tx);
1728 remote_response_rxs.push((i, response_rx));
1729 }
1730 let cancel = Arc::new(AtomicBool::new(false));
1731 let engine_clock = EngineClock::default();
1732 *guard = Some(EngineSession::Forge {
1733 game_id: game_id.clone(),
1734 remote_response_txs,
1735 cancel: cancel.clone(),
1736 engine_clock: engine_clock.clone(),
1737 });
1738 drop(guard);
1739
1740 spawn_remote_prompt_forwarder(
1741 outbound_tx.clone(),
1742 snapshot.clone(),
1743 engine_session.clone(),
1744 game_id.clone(),
1745 remote_prompt_rx,
1746 Some(player_names.clone()),
1747 config.state_delta,
1748 engine_clock,
1749 );
1750 let (game_over_tx, game_over_rx) = std_mpsc::channel::<HostedGameOver>();
1751 spawn_game_over_forwarder(
1752 outbound_tx.clone(),
1753 game_over_rx,
1754 engine_session.clone(),
1755 snapshot.clone(),
1756 game_id.clone(),
1757 Some(player_names.clone()),
1758 );
1759 let outbound_tx = outbound_tx.clone();
1760 let snapshot = snapshot.clone();
1761 spawn_engine_thread(move || {
1762 info!(
1763 game_id,
1764 backend = backend.label(),
1765 players = num_players,
1766 local_player_index,
1767 "starting hosted engine thread"
1768 );
1769 crate::metrics::record_engine_session_started();
1770 let started = Instant::now();
1771 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1772 java_backend::run_hosted_engine_game(
1773 game_id.clone(),
1774 player_names,
1775 ordered_decks,
1776 commander_names,
1777 commander_variant,
1778 game_variant,
1779 local_player_index,
1780 ai_player_indices,
1781 starting_life,
1782 remote_prompt_tx,
1783 remote_response_rxs,
1784 game_over_tx,
1785 cancel,
1786 )
1787 }));
1788 finish_hosted_engine(
1789 result,
1790 &game_id,
1791 num_players,
1792 started,
1793 &outbound_tx,
1794 &session_handle,
1795 &snapshot,
1796 );
1797 });
1798 }
1799 }
1800}
1801
1802fn clear_engine_session(engine_session: &SharedEngineSession) {
1803 match engine_session.lock() {
1804 Ok(mut guard) => *guard = None,
1805 Err(error) => warn!(%error, "engine session lock poisoned on reset"),
1806 }
1807}
1808
1809fn active_player_usernames(snapshot: &SharedHostSnapshot) -> Option<HashSet<String>> {
1814 let snap = snapshot.lock().ok()?;
1815 let game = snap.game.as_ref()?;
1816 let mut active: HashSet<String> = game.player_order.iter().cloned().collect();
1817 let players = snap
1818 .last_state
1819 .as_ref()
1820 .and_then(|state| state.pointer("/state/gameView/players"))
1821 .and_then(Value::as_array);
1822 if let Some(players) = players {
1823 for player in players {
1824 let playing = player
1825 .get("status")
1826 .and_then(Value::as_str)
1827 .is_none_or(|status| status == "playing");
1828 if playing {
1829 continue;
1830 }
1831 let index = player
1832 .get("id")
1833 .and_then(Value::as_str)
1834 .and_then(parse_player_slot);
1835 if let Some(username) = index.and_then(|i| game.player_order.get(i)) {
1836 active.remove(username);
1837 }
1838 }
1839 }
1840 Some(active)
1841}
1842
1843fn seat_index_of(snapshot: &SharedHostSnapshot, username: &str) -> Option<usize> {
1844 let snap = snapshot.lock().ok()?;
1845 let game = snap.game.as_ref()?;
1846 game.player_order.iter().position(|name| name == username)
1847}
1848
1849fn concede_missing_active_seats(
1850 engine_session: &SharedEngineSession,
1851 snapshot: &SharedHostSnapshot,
1852 room: &RoomInfo,
1853) {
1854 let Some(active) = active_player_usernames(snapshot) else {
1855 return;
1856 };
1857 let present: HashSet<&str> = room
1858 .players
1859 .iter()
1860 .map(|player| player.username.as_str())
1861 .collect();
1862 for username in active {
1863 if !present.contains(username.as_str()) {
1864 if let Some(index) = seat_index_of(snapshot, &username) {
1865 concede_seat(engine_session, index);
1866 }
1867 }
1868 }
1869}
1870
1871fn concede_seat(engine_session: &SharedEngineSession, player_index: usize) {
1872 send_seat_message(
1873 engine_session,
1874 player_index,
1875 ClientToServerMessage::Directive {
1876 directive: manabrew_protocol::transport::DirectiveInput::Concede,
1877 },
1878 );
1879}
1880
1881fn send_seat_message(
1882 engine_session: &SharedEngineSession,
1883 player_index: usize,
1884 message: ClientToServerMessage,
1885) {
1886 let guard = match engine_session.lock() {
1887 Ok(guard) => guard,
1888 Err(error) => {
1889 warn!(%error, "engine session lock poisoned");
1890 return;
1891 }
1892 };
1893 let Some(session) = guard.as_ref() else {
1894 debug!(player_index, "no engine session for seat action");
1895 return;
1896 };
1897 let txs = match session {
1898 EngineSession::Manabrew {
1899 remote_response_txs,
1900 ..
1901 }
1902 | EngineSession::Forge {
1903 remote_response_txs,
1904 ..
1905 } => remote_response_txs,
1906 };
1907 let Some(tx) = txs.get(&player_index) else {
1908 debug!(player_index, "no response channel for player");
1909 return;
1910 };
1911 if let Err(error) = tx.send(message) {
1912 warn!(player_index, %error, "failed to route seat message");
1913 }
1914}
1915
1916fn finish_hosted_engine(
1917 result: std::thread::Result<Result<(), String>>,
1918 game_id: &str,
1919 players: usize,
1920 started: Instant,
1921 outbound_tx: &tokio_mpsc::UnboundedSender<ClientMessage>,
1922 session_handle: &SharedEngineSession,
1923 snapshot: &SharedHostSnapshot,
1924) {
1925 let fatal = match result {
1926 Ok(Ok(())) => {
1927 info!(game_id, "hosted engine thread finished");
1928 None
1929 }
1930 Ok(Err(message)) => {
1931 error!(game_id, message, "hosted engine exited with a fatal error");
1932 Some(message)
1933 }
1934 Err(panic) => {
1935 let message = if let Some(message) = panic.downcast_ref::<String>() {
1936 message.clone()
1937 } else if let Some(message) = panic.downcast_ref::<&str>() {
1938 message.to_string()
1939 } else {
1940 "the host engine panicked".to_string()
1941 };
1942 error!(game_id, message, "hosted engine thread panicked");
1943 Some(message)
1944 }
1945 };
1946 crate::metrics::record_engine_session_finished(players, started, fatal.as_deref());
1947 let still_owner = session_handle
1948 .lock()
1949 .map(|guard| guard.as_ref().is_some_and(|s| s.game_id() == game_id))
1950 .unwrap_or(false);
1951 if let Some(message) = fatal {
1952 if still_owner {
1953 if let Ok(mut snap) = snapshot.lock() {
1954 if snap
1955 .game
1956 .as_ref()
1957 .is_some_and(|game| game.game_id == game_id)
1958 {
1959 snap.pending_end_game = Some(game_id.to_string());
1960 }
1961 }
1962 let _ = outbound_tx.send(ClientMessage::ReportGameOutcome {
1963 game_id: game_id.to_string(),
1964 outcome: GameOutcomeReport {
1965 fatal_message: Some(message.clone()),
1966 ..GameOutcomeReport::default()
1967 },
1968 });
1969 if let Ok(state) = serde_json::to_value(StateEnvelope::Fatal { message }) {
1970 let _ = outbound_tx.send(ClientMessage::BroadcastState {
1971 state,
1972 target_player: None,
1973 });
1974 }
1975 let _ = outbound_tx.send(ClientMessage::EndGame {
1976 game_id: game_id.to_string(),
1977 });
1978 } else {
1979 warn!(game_id, message, "stale engine session finished with error");
1980 }
1981 }
1982}
1983
1984fn route_seat_envelope(
1986 engine_session: &SharedEngineSession,
1987 snapshot: &SharedHostSnapshot,
1988 from_player: &str,
1989 state: &Value,
1990) {
1991 match serde_json::from_value::<StateEnvelope>(state.clone()) {
1992 Ok(StateEnvelope::Response { .. }) => {
1993 route_remote_response(engine_session, snapshot, from_player, state)
1994 }
1995 Ok(StateEnvelope::Directive {
1996 from_player: claimed_slot,
1997 directive,
1998 }) => route_remote_directive(
1999 engine_session,
2000 snapshot,
2001 from_player,
2002 &claimed_slot,
2003 &directive,
2004 ),
2005 _ => debug!(from_player, state = %state, "unroutable seat envelope"),
2006 }
2007}
2008
2009fn route_remote_response(
2010 engine_session: &SharedEngineSession,
2011 snapshot: &SharedHostSnapshot,
2012 authenticated_username: &str,
2013 state: &Value,
2014) {
2015 let envelope: StateEnvelope = match serde_json::from_value(state.clone()) {
2016 Ok(envelope) => envelope,
2017 Err(error) => {
2018 warn!(%error, state = %state, "relay response invalid envelope");
2019 return;
2020 }
2021 };
2022 let StateEnvelope::Response {
2023 from_player,
2024 prompt_id,
2025 action: action_value,
2026 } = envelope
2027 else {
2028 warn!(state = %state, "expected response envelope");
2029 return;
2030 };
2031 let Some(player_index) =
2032 authenticated_player_index(snapshot, authenticated_username, &from_player, "response")
2033 else {
2034 return;
2035 };
2036 let action: PromptOutput = match serde_json::from_value(action_value) {
2037 Ok(action) => action,
2038 Err(error) => {
2039 warn!(from_player, %error, "relay response has invalid action");
2040 return;
2041 }
2042 };
2043
2044 let guard = match engine_session.lock() {
2045 Ok(guard) => guard,
2046 Err(error) => {
2047 warn!(%error, "engine session lock poisoned");
2048 return;
2049 }
2050 };
2051 let Some(session) = guard.as_ref() else {
2052 debug!(from_player, "no engine session for relay response");
2053 return;
2054 };
2055 let tx = match session {
2056 EngineSession::Manabrew {
2057 remote_response_txs,
2058 ..
2059 } => remote_response_txs.get(&player_index),
2060 EngineSession::Forge {
2061 remote_response_txs,
2062 ..
2063 } => {
2064 debug!(
2065 from_player,
2066 player_index, "routing relay response to java engine"
2067 );
2068 remote_response_txs.get(&player_index)
2069 }
2070 };
2071 let Some(tx) = tx else {
2072 debug!(from_player, player_index, "no response channel for player");
2073 return;
2074 };
2075 session.engine_clock().mark_response();
2076 if let Err(error) = tx.send(ClientToServerMessage::Response { prompt_id, action }) {
2077 warn!(from_player, %error, "failed to route relay response");
2078 return;
2079 }
2080 drop(guard);
2081 if let Ok(mut snap) = snapshot.lock() {
2082 snap.pending_prompts.remove(&from_player);
2083 }
2084}
2085
2086fn route_remote_directive(
2087 engine_session: &SharedEngineSession,
2088 snapshot: &SharedHostSnapshot,
2089 authenticated_username: &str,
2090 claimed_slot: &str,
2091 directive: &Value,
2092) {
2093 let directive: DirectiveInput = match serde_json::from_value(directive.clone()) {
2094 Ok(directive) => directive,
2095 Err(error) => {
2096 warn!(claimed_slot, %error, "relay directive is invalid");
2097 return;
2098 }
2099 };
2100 let Some(player_index) =
2101 authenticated_player_index(snapshot, authenticated_username, claimed_slot, "directive")
2102 else {
2103 return;
2104 };
2105 info!(claimed_slot, player_index, ?directive, "routing directive");
2106 match directive {
2107 DirectiveInput::Concede => concede_seat(engine_session, player_index),
2108 }
2109}
2110
2111fn authenticated_player_index(
2112 snapshot: &SharedHostSnapshot,
2113 authenticated_username: &str,
2114 claimed_slot: &str,
2115 message_kind: &str,
2116) -> Option<usize> {
2117 let Some(player_index) = seat_index_of(snapshot, authenticated_username) else {
2118 warn!(
2119 authenticated_username,
2120 message_kind, "relay sender has no engine seat"
2121 );
2122 return None;
2123 };
2124 let authenticated_slot = player_slot(player_index);
2125 if claimed_slot != authenticated_slot {
2126 warn!(
2127 authenticated_username,
2128 claimed_slot,
2129 authenticated_slot,
2130 message_kind,
2131 "relay sender claimed another engine seat"
2132 );
2133 return None;
2134 }
2135 Some(player_index)
2136}
2137
2138fn stamp_fingerprint(mut envelope: Value) -> Value {
2141 let Some(state) = envelope.get("state") else {
2142 return envelope;
2143 };
2144 let fingerprint = manabrew_relay_protocol::state_delta::fingerprint(state);
2145 if let Some(object) = envelope.as_object_mut() {
2146 object.insert("fingerprint".to_string(), Value::String(fingerprint));
2147 }
2148 envelope
2149}
2150
2151fn stamp_emit_ms(mut envelope: Value, elapsed: Duration) -> Value {
2155 if let Some(object) = envelope.as_object_mut() {
2156 object.insert(
2157 "emitMs".to_string(),
2158 Value::from(elapsed.as_millis().min(u128::from(u32::MAX)) as u32),
2159 );
2160 }
2161 envelope
2162}
2163
2164#[derive(Clone, Default)]
2172pub struct EngineClock(std::sync::Arc<std::sync::atomic::AtomicU64>);
2173
2174impl EngineClock {
2175 fn now() -> u64 {
2176 use std::sync::OnceLock;
2177 static START: OnceLock<Instant> = OnceLock::new();
2178 START.get_or_init(Instant::now).elapsed().as_millis() as u64
2179 }
2180
2181 pub fn mark_response(&self) {
2182 self.0
2183 .store(Self::now(), std::sync::atomic::Ordering::Relaxed);
2184 }
2185
2186 pub fn mark_decision_complete(&self) {
2189 self.0.store(0, std::sync::atomic::Ordering::Relaxed);
2190 }
2191
2192 pub fn elapsed_ms(&self) -> Option<u32> {
2202 let at = self.0.load(std::sync::atomic::Ordering::Relaxed);
2203 if at == 0 {
2204 return None;
2205 }
2206 let delta = Self::now().saturating_sub(at);
2207 Some(delta.min(u32::MAX as u64) as u32)
2208 }
2209}
2210
2211fn patch_against_last(
2212 bases: &mut HashMap<usize, (Value, String)>,
2213 player_index: usize,
2214 per_seat: bool,
2215 slot: &str,
2216 envelope: &Value,
2217 engine_ms: Option<u32>,
2218) -> Option<Value> {
2219 let next = envelope.get("state")?.clone();
2220 let fingerprint = envelope.get("fingerprint")?.as_str()?.to_string();
2221 let previous = bases.insert(player_index, (next.clone(), fingerprint.clone()));
2222 let (base_state, base) = previous?;
2223 let patch = manabrew_relay_protocol::state_delta::diff(&base_state, &next)?;
2224 serde_json::to_value(StateEnvelope::StateDelta {
2225 for_player: per_seat.then(|| slot.to_string()),
2226 base,
2227 fingerprint,
2228 patch,
2229 engine_ms,
2230 emit_ms: None,
2231 })
2232 .ok()
2233}
2234
2235fn spawn_remote_prompt_forwarder(
2236 outbound_tx: tokio_mpsc::UnboundedSender<ClientMessage>,
2237 snapshot: SharedHostSnapshot,
2238 engine_session: SharedEngineSession,
2239 game_id: String,
2240 remote_prompt_rx: std_mpsc::Receiver<(usize, AgentMessage)>,
2241 seat_usernames: Option<Vec<String>>,
2242 state_delta: bool,
2243 engine_clock: EngineClock,
2244) {
2245 thread::spawn(move || {
2246 let mut last_state_by_seat: HashMap<usize, Value> = HashMap::new();
2247 let mut delta_bases: HashMap<usize, (Value, String)> = HashMap::new();
2248 let mut last_state: Option<Value> = None;
2249 let mut last_display: Option<Value> = None;
2250 while let Ok((player_index, message)) = remote_prompt_rx.recv() {
2251 let Ok(session) = engine_session.lock() else {
2252 break;
2253 };
2254 if session
2255 .as_ref()
2256 .is_none_or(|session| session.game_id() != game_id)
2257 {
2258 break;
2259 }
2260 let per_seat = seat_usernames.is_some() && player_index != OBSERVER_SEAT;
2261 let slot = player_slot(player_index);
2262 let engine_ms = engine_clock.elapsed_ms();
2263 if matches!(message, AgentMessage::Prompt(_)) {
2264 engine_clock.mark_decision_complete();
2265 }
2266 let emit_started = Instant::now();
2267 let envelope = match &message {
2268 AgentMessage::State(state_update) if !per_seat => StateEnvelope::State {
2269 for_player: None,
2270 state: serde_json::to_value(state_update).unwrap_or(Value::Null),
2271 fingerprint: None,
2272 engine_ms,
2273 emit_ms: None,
2274 },
2275 _ => StateEnvelope::for_agent_message_timed(slot.clone(), &message, engine_ms),
2276 };
2277 let Ok(state) = serde_json::to_value(envelope) else {
2278 continue;
2279 };
2280 let state = match &message {
2281 AgentMessage::State(_) if state_delta => stamp_fingerprint(state),
2282 _ => state,
2283 };
2284 match &message {
2285 AgentMessage::State(_) if per_seat => {
2286 if last_state_by_seat.get(&player_index) == Some(&state) {
2287 continue;
2288 }
2289 last_state_by_seat.insert(player_index, state.clone());
2290 }
2291 AgentMessage::State(_) if last_state.as_ref() == Some(&state) => continue,
2292 AgentMessage::State(_) => last_state = Some(state.clone()),
2293 AgentMessage::Display(_) if last_display.as_ref() == Some(&state) => continue,
2294 AgentMessage::Display(_) => last_display = Some(state.clone()),
2295 AgentMessage::Prompt(_) | AgentMessage::Error(_) => {}
2296 }
2297 if let Ok(mut snap) = snapshot.lock() {
2298 match &message {
2299 AgentMessage::State(_) if per_seat => {
2300 snap.last_state_by_slot.insert(slot.clone(), state.clone());
2301 }
2302 AgentMessage::State(_) => snap.last_state = Some(state.clone()),
2303 AgentMessage::Prompt(_) => {
2304 snap.pending_prompts.insert(slot.clone(), state.clone());
2305 }
2306 AgentMessage::Display(_) | AgentMessage::Error(_) => {}
2307 }
2308 }
2309 let state = match &message {
2310 AgentMessage::State(_) if state_delta => patch_against_last(
2311 &mut delta_bases,
2312 player_index,
2313 per_seat,
2314 &slot,
2315 &state,
2316 engine_ms,
2317 )
2318 .unwrap_or(state),
2319 _ => state,
2320 };
2321 let target_player = if per_seat
2322 && matches!(
2323 message,
2324 AgentMessage::State(_) | AgentMessage::Prompt(_) | AgentMessage::Error(_)
2325 ) {
2326 let Some(target_player) = seat_usernames
2327 .as_ref()
2328 .and_then(|names| names.get(player_index).cloned())
2329 else {
2330 warn!(player_index, PRIVATE_MESSAGE_MISSING_PLAYER);
2331 continue;
2332 };
2333 Some(target_player)
2334 } else {
2335 None
2336 };
2337 if outbound_tx
2338 .send(ClientMessage::BroadcastState {
2339 state: stamp_emit_ms(state, emit_started.elapsed()),
2340 target_player,
2341 })
2342 .is_err()
2343 {
2344 break;
2345 }
2346 }
2347 });
2348}
2349
2350fn outcome_report(view: &GameViewDto) -> GameOutcomeReport {
2351 GameOutcomeReport {
2352 game_over: view.game_over,
2353 winner_slot: view.winner_id.clone(),
2354 conceded_slots: view
2355 .players
2356 .iter()
2357 .filter(|player| player.status == PlayerStatus::Conceded)
2358 .map(|player| player.id.clone())
2359 .collect(),
2360 fatal_message: None,
2361 turns: Some(view.turn),
2362 }
2363}
2364
2365fn cached_outcome_report(envelope: &Value) -> Option<GameOutcomeReport> {
2366 let update: StateUpdate = serde_json::from_value(envelope.get("state")?.clone()).ok()?;
2367 Some(outcome_report(&update.game_view))
2368}
2369
2370fn spawn_game_over_forwarder(
2371 outbound_tx: tokio_mpsc::UnboundedSender<ClientMessage>,
2372 game_over_rx: std_mpsc::Receiver<HostedGameOver>,
2373 session_handle: SharedEngineSession,
2374 snapshot: SharedHostSnapshot,
2375 game_id: String,
2376 seat_usernames: Option<Vec<String>>,
2377) {
2378 thread::spawn(move || {
2379 while let Ok(game_over) = game_over_rx.recv() {
2380 let Ok(session) = session_handle.lock() else {
2381 return;
2382 };
2383 if session
2384 .as_ref()
2385 .is_none_or(|session| session.game_id() != game_id)
2386 {
2387 warn!(
2388 game_id,
2389 "stale engine session reached game over; not ending the relay game"
2390 );
2391 continue;
2392 }
2393 if let Ok(mut snap) = snapshot.lock() {
2394 if snap
2395 .game
2396 .as_ref()
2397 .is_some_and(|game| game.game_id == game_id)
2398 {
2399 snap.pending_end_game = Some(game_id.clone());
2400 }
2401 }
2402 let outcome = game_over
2403 .messages
2404 .iter()
2405 .rev()
2406 .find_map(|(_, message)| match message {
2407 AgentMessage::State(update) => Some(outcome_report(&update.game_view)),
2408 _ => None,
2409 })
2410 .or_else(|| {
2411 let snap = snapshot.lock().ok()?;
2412 snap.last_state
2413 .as_ref()
2414 .or_else(|| snap.last_state_by_slot.values().next())
2415 .and_then(cached_outcome_report)
2416 .filter(|outcome| outcome.game_over)
2417 });
2418 let mut last_state_by_seat: HashMap<usize, Value> = HashMap::new();
2419 let mut last_state: Option<Value> = None;
2420 for (player_index, message) in game_over.messages {
2421 let per_seat = seat_usernames.is_some() && player_index != OBSERVER_SEAT;
2422 let envelope = match &message {
2423 AgentMessage::State(state_update) if !per_seat => StateEnvelope::State {
2424 for_player: None,
2425 state: serde_json::to_value(state_update).unwrap_or(Value::Null),
2426 fingerprint: None,
2427 engine_ms: None,
2428 emit_ms: None,
2429 },
2430 _ => StateEnvelope::for_agent_message(player_slot(player_index), &message),
2431 };
2432 let Ok(state) = serde_json::to_value(envelope) else {
2433 continue;
2434 };
2435 match &message {
2436 AgentMessage::State(_) if per_seat => {
2437 if last_state_by_seat.get(&player_index) == Some(&state) {
2438 continue;
2439 }
2440 last_state_by_seat.insert(player_index, state.clone());
2441 }
2442 AgentMessage::State(_) if last_state.as_ref() == Some(&state) => continue,
2443 AgentMessage::State(_) => last_state = Some(state.clone()),
2444 AgentMessage::Display(_) | AgentMessage::Prompt(_) | AgentMessage::Error(_) => {
2445 }
2446 }
2447 let target_player = if per_seat
2448 && matches!(
2449 message,
2450 AgentMessage::State(_) | AgentMessage::Prompt(_) | AgentMessage::Error(_)
2451 ) {
2452 let Some(target_player) = seat_usernames
2453 .as_ref()
2454 .and_then(|names| names.get(player_index).cloned())
2455 else {
2456 warn!(player_index, PRIVATE_MESSAGE_MISSING_PLAYER);
2457 continue;
2458 };
2459 Some(target_player)
2460 } else {
2461 None
2462 };
2463 if outbound_tx
2464 .send(ClientMessage::BroadcastState {
2465 state,
2466 target_player,
2467 })
2468 .is_err()
2469 {
2470 return;
2471 }
2472 }
2473 let Ok(state) = serde_json::to_value(StateEnvelope::RoomRelay {
2474 protocol: SELF_HOSTED_NODE_PROTOCOL.to_string(),
2475 version: 1,
2476 message_id: Uuid::new_v4().to_string(),
2477 from_player: None,
2478 target_player: None,
2479 room_id: None,
2480 payload: json!({ "type": "gameOver", "gameId": game_over.game_id }),
2481 }) else {
2482 continue;
2483 };
2484 if outbound_tx
2485 .send(ClientMessage::BroadcastState {
2486 state,
2487 target_player: None,
2488 })
2489 .is_err()
2490 {
2491 return;
2492 }
2493 if let Some(outcome) = outcome {
2494 let _ = outbound_tx.send(ClientMessage::ReportGameOutcome {
2495 game_id: game_id.clone(),
2496 outcome,
2497 });
2498 }
2499 if outbound_tx
2500 .send(ClientMessage::EndGame {
2501 game_id: game_id.clone(),
2502 })
2503 .is_err()
2504 {
2505 return;
2506 }
2507 }
2508 });
2509}
2510
2511async fn relay_writer(mut write: WsWrite, mut outbound: tokio_mpsc::UnboundedReceiver<Message>) {
2512 while let Some(message) = outbound.recv().await {
2513 let started = Instant::now();
2514 let closing = matches!(message, Message::Close(_));
2515 if let Err(error) = write.send(message).await {
2516 warn!(%error, "relay write failed");
2517 break;
2518 }
2519 crate::metrics::record_relay_send(started.elapsed());
2520 if closing {
2521 break;
2522 }
2523 }
2524 let _ = write.close().await;
2525}
2526
2527fn self_minted_identity_token(kind: &str, handle: &str) -> String {
2528 let iat = std::time::SystemTime::now()
2529 .duration_since(std::time::UNIX_EPOCH)
2530 .map(|elapsed| elapsed.as_secs() as i64)
2531 .unwrap_or_default();
2532 identity_token::mint_unsigned(&format!("{kind}:{handle}"), handle, iat, 24 * 60 * 60)
2533}
2534
2535impl RelayClient {
2536 async fn connect(
2537 relay_url: &str,
2538 username: &str,
2539 password: &str,
2540 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
2541 info!(relay_url, username, "connecting relay client");
2542 let (socket, _) = connect_async_with_config(relay_url, None, true).await?;
2543 let (write, read) = socket.split();
2544 let (outbound, outbound_rx) = tokio_mpsc::unbounded_channel();
2545 let writer = tokio::spawn(relay_writer(write, outbound_rx));
2546 let mut client = Self {
2547 username: username.to_string(),
2548 outbound,
2549 read,
2550 writer,
2551 };
2552 client
2553 .send(&ClientMessage::Authenticate {
2554 username: username.to_string(),
2555 password: password.to_string(),
2556 service: true,
2557 identity: Some(IdentityProof {
2558 token: Some(self_minted_identity_token("node", username)),
2559 device: None,
2560 }),
2561 client_platform: ClientPlatform::Unknown,
2562 client_version: None,
2563 })
2564 .await?;
2565 client.wait_for_auth().await?;
2566 Ok(client)
2567 }
2568
2569 async fn wait_for_auth(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2570 loop {
2571 match self.recv().await? {
2572 Some(ServerMessage::AuthResult { success, error, .. }) => {
2573 if success {
2574 info!(username = %self.username, "authenticated");
2575 return Ok(());
2576 }
2577 return Err(format!("authentication failed: {error:?}").into());
2578 }
2579 Some(other) => {
2580 debug!(?other, username = %self.username, "ignored pre-auth message")
2581 }
2582 None => return Err("relay closed before authentication".into()),
2583 }
2584 }
2585 }
2586
2587 async fn send(
2588 &mut self,
2589 message: &ClientMessage,
2590 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2591 self.enqueue(Message::Text(serde_json::to_string(message)?))
2592 }
2593
2594 fn enqueue(&self, message: Message) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2595 self.outbound
2596 .send(message)
2597 .map_err(|_| "relay writer stopped".into())
2598 }
2599
2600 async fn recv(
2601 &mut self,
2602 ) -> Result<Option<ServerMessage>, Box<dyn std::error::Error + Send + Sync>> {
2603 loop {
2604 let Some(message) = self.read.next().await else {
2605 return Ok(None);
2606 };
2607 let message = message?;
2608 match message {
2609 Message::Text(text) => {
2610 return Ok(Some(serde_json::from_str(&text)?));
2611 }
2612 Message::Ping(payload) => {
2613 self.enqueue(Message::Pong(payload))?;
2614 }
2615 Message::Close(_) => return Ok(None),
2616 _ => {}
2617 }
2618 }
2619 }
2620
2621 async fn close(&mut self) {
2622 if self.enqueue(Message::Close(None)).is_err() {
2623 return;
2624 }
2625 let _ = time::timeout(CLOSE_DRAIN_TIMEOUT, async {
2626 while let Some(Ok(_)) = self.read.next().await {}
2627 })
2628 .await;
2629 self.writer.abort();
2630 }
2631
2632 async fn broadcast_room_message(
2633 &mut self,
2634 protocol: &str,
2635 payload: Value,
2636 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2637 let envelope = StateEnvelope::RoomRelay {
2638 protocol: protocol.to_string(),
2639 version: 1,
2640 message_id: Uuid::new_v4().to_string(),
2641 from_player: Some(self.username.clone()),
2642 target_player: None,
2643 room_id: None,
2644 payload,
2645 };
2646 self.send(&ClientMessage::BroadcastState {
2647 state: serde_json::to_value(envelope)?,
2648 target_player: None,
2649 })
2650 .await
2651 }
2652}
2653
2654fn log_room_update(observer: &str, room: &RoomInfo) {
2655 let players = room
2656 .players
2657 .iter()
2658 .map(|player| {
2659 format!(
2660 "{}{}{}",
2661 player.username,
2662 if player.ready { ":ready" } else { "" },
2663 if player.connected { "" } else { ":offline" },
2664 )
2665 })
2666 .collect::<Vec<_>>()
2667 .join(",");
2668 info!(
2669 observer,
2670 room_id = %room.room_id,
2671 room_name = %room.room_name,
2672 host = %room.host,
2673 players,
2674 "room update"
2675 );
2676}