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