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