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
52type 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 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
192pub(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
328pub 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
342pub 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
353pub 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 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
652fn 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 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 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
987fn 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 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 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
1554fn 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
1575fn 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
1877fn 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
2052fn 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 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
2228fn 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
2241fn 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#[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 pub fn mark_decision_complete(&self) {
2279 self.0.store(0, std::sync::atomic::Ordering::Relaxed);
2280 }
2281
2282 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
2325fn 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
2358fn 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 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 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 relay_response(&rig, 7);
2995 assert!(
2996 rig.bot_rx.try_recv().is_err(),
2997 "relay duplicate reached the engine"
2998 );
2999
3000 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}