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