Skip to main content

self_hosted_node/
host.rs

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