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