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