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