self_hosted_node/engine_backend/
rust_backend.rs1use std::collections::HashMap;
2use std::env;
3use std::path::PathBuf;
4use std::sync::atomic::AtomicBool;
5use std::sync::{mpsc as std_mpsc, Arc, Once, OnceLock};
6
7use manabrew_agent_interface::agent_impl::PromptAgent;
8use manabrew_agent_interface::prompt::{AgentMessage, ClientToServerMessage};
9use manabrew_protocol::deck_dto::Deck;
10
11use crate::config::DeckSelection;
12use forge_carddb::CardDatabase;
13use manabot::BotResponder;
14use manabrew_engine::agent::PlayerAgent;
15use manabrew_engine::game::TypeRegistry;
16use manabrew_engine::ids::PlayerId;
17use manabrew_game_runtime::deck::prepare_players;
18use manabrew_game_runtime::host_runtime::{
19 register_tokens_from_db, run_hosted_multiplayer_game, DEFAULT_MAX_TURNS,
20};
21use manabrew_game_runtime::mpsc_transport::MpscTransport as NodeTransport;
22use memmap2::Mmap;
23use rand::SeedableRng;
24use tracing::{info, warn};
25
26use super::HostedGameOver;
27use crate::config::workspace_root;
28
29#[allow(clippy::too_many_arguments)]
30pub fn run_hosted_engine_game(
31 game_id: String,
32 player_names: Vec<String>,
33 decks: Vec<Deck>,
34 commander_names: Vec<Option<String>>,
35 local_player_index: Option<usize>,
36 starting_life: i32,
37 remote_prompt_tx: std_mpsc::Sender<(usize, AgentMessage)>,
38 remote_response_rxs: Vec<(usize, std_mpsc::Receiver<ClientToServerMessage>)>,
39 game_over_tx: std_mpsc::Sender<HostedGameOver>,
40) -> Result<(), String> {
41 let prepared_players = prepare_players(
42 &player_names,
43 &decks,
44 &commander_names,
45 get_card_db(),
46 starting_life,
47 );
48
49 let mut local_ai: Option<Box<dyn PlayerAgent>> = local_player_index.map(|player_index| {
53 Box::new(PromptAgent::new(
54 PlayerId(player_index as u32),
55 game_id.clone(),
56 BotResponder::default(),
57 )) as Box<dyn PlayerAgent>
58 });
59 let mut remote_rx_map: HashMap<usize, std_mpsc::Receiver<ClientToServerMessage>> =
60 remote_response_rxs.into_iter().collect();
61 let game_id_for_agents = game_id.clone();
62 let remote_prompt_tx_for_agents = remote_prompt_tx.clone();
63
64 let mut rng = rand::rngs::StdRng::from_entropy();
65 let abort_signal = Arc::new(AtomicBool::new(false));
66 run_hosted_multiplayer_game(
67 prepared_players,
68 abort_signal,
69 DEFAULT_MAX_TURNS,
70 &mut rng,
71 |game_loop| register_tokens_from_db(game_loop, get_token_db()),
72 |pid| {
73 if Some(pid.index()) == local_player_index {
74 local_ai
75 .take()
76 .expect("agent_factory called twice for the local seat")
77 } else {
78 let i = pid.index();
79 let response_rx = remote_rx_map
80 .remove(&i)
81 .expect("missing remote response receiver");
82 Box::new(PromptAgent::new(
83 pid,
84 game_id_for_agents.clone(),
85 NodeTransport::new_relay(i, remote_prompt_tx_for_agents.clone(), response_rx),
86 ))
87 }
88 },
89 );
90 let _ = game_over_tx.send(HostedGameOver {
91 game_id,
92 messages: Vec::new(),
95 });
96 Ok(())
97}
98
99pub fn run_self_play(
100 seats: &[DeckSelection],
101 starting_life: i32,
102 seed: u64,
103 max_turns: u32,
104) -> Result<(), String> {
105 let names: Vec<String> = (1..=seats.len())
106 .map(|i| format!("Self-Play {i}"))
107 .collect();
108 let decks: Vec<Deck> = seats.iter().map(|seat| seat.deck.clone()).collect();
109 let commander_names: Vec<Option<String>> = seats
110 .iter()
111 .map(|seat| seat.commander_name.clone())
112 .collect();
113 let prepared_players = prepare_players(
114 &names,
115 &decks,
116 &commander_names,
117 get_card_db(),
118 starting_life,
119 );
120
121 let game_id = "self-hosted-rust-self-play".to_string();
122 let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
123 let abort_signal = Arc::new(AtomicBool::new(false));
124 info!(
125 players = seats.len(),
126 starting_life, seed, max_turns, "rust self-play session started"
127 );
128 let outcome = run_hosted_multiplayer_game(
129 prepared_players,
130 abort_signal,
131 max_turns,
132 &mut rng,
133 |game_loop| register_tokens_from_db(game_loop, get_token_db()),
134 move |pid| {
135 Box::new(PromptAgent::new(
136 pid,
137 game_id.clone(),
138 BotResponder::default(),
139 )) as Box<dyn PlayerAgent>
140 },
141 );
142
143 if outcome.aborted {
144 return Err("rust self-play aborted".to_string());
145 }
146 match outcome.winner {
147 Some(winner) => {
148 info!(winner = winner.index(), "rust self-play reached game over");
149 Ok(())
150 }
151 None => Err(format!(
152 "rust self-play hit {max_turns} turns without a winner"
153 )),
154 }
155}
156
157static CARD_DB: OnceLock<CardDatabase> = OnceLock::new();
161static TOKEN_DB: OnceLock<CardDatabase> = OnceLock::new();
162static DB_INIT: Once = Once::new();
163
164fn ensure_dbs_loaded() {
165 DB_INIT.call_once(|| {
166 let archive_path = cardset_archive_path();
167 info!(path = %archive_path.display(), "loading card + token databases from archive");
168 let file = std::fs::File::open(&archive_path).unwrap_or_else(|e| {
169 panic!(
170 "Cardset archive not found at {}: {e}. Run `cargo run -p forge-cardset-archive --features build --release --bin build-cardset-archive` to build it.",
171 archive_path.display()
172 )
173 });
174 let mmap = unsafe { Mmap::map(&file).expect("mmap cardset archive") };
175 let bundle =
176 CardDatabase::load_from_archive(&mmap).expect("load cardset archive");
177 info!(
178 cards_loaded = bundle.cards_result.loaded,
179 cards_failed = bundle.cards_result.failed,
180 tokens_loaded = bundle.tokens_result.loaded,
181 tokens_failed = bundle.tokens_result.failed,
182 "loaded archive"
183 );
184 for (file, error) in bundle.tokens_result.errors.iter().take(10) {
185 warn!(file, %error, "token parse error");
186 }
187 if let Some(archive) = bundle.cards.archive() {
188 TypeRegistry::load(archive.type_lists.as_str());
189 }
190 let _ = CARD_DB.set(bundle.cards);
191 let _ = TOKEN_DB.set(bundle.tokens);
192 });
193}
194
195fn get_card_db() -> &'static CardDatabase {
196 ensure_dbs_loaded();
197 CARD_DB.get().expect("card db must be initialized")
198}
199
200fn get_token_db() -> &'static CardDatabase {
201 ensure_dbs_loaded();
202 TOKEN_DB.get().expect("token db must be initialized")
203}
204
205fn cardset_archive_path() -> PathBuf {
206 env::var("CARDSET_ARCHIVE")
207 .map(PathBuf::from)
208 .unwrap_or_else(|_| workspace_root().join("src-tauri/resources/cardset.rkyv"))
209}