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