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