1use std::env;
2use std::path::{Path, PathBuf};
3
4use manabrew_agent_interface::protocol::GameFormat;
5use manabrew_protocol::deck_dto::{Deck, DeckCard, DeckCardIdentity};
6use serde::Deserialize;
7use tracing::warn;
8
9use crate::engine_backend::EngineBackendKind;
10
11#[derive(Debug, Clone)]
12pub struct Config {
13 pub backend: EngineBackendKind,
14 pub relay_url: String,
15 pub username: String,
16 pub password: String,
17 pub room_id: Option<String>,
18 pub room_name: String,
19 pub max_players: u8,
20 pub max_games: usize,
21 pub state_delta: bool,
24 pub format: GameFormat,
25 pub auto_start: bool,
26 pub engine_enabled: bool,
27 pub host_plays: bool,
28 pub official_key: Option<String>,
29 pub room_password: Option<String>,
30 pub bot_enabled: bool,
31 pub bot_username: String,
32 pub forge_ai: bool,
33 pub bot_local_answers: bool,
35 pub reconnect_timeout_s: Option<u32>,
36 pub table_style: Option<String>,
37 pub host_deck: DeckSelection,
38 pub bot_deck: DeckSelection,
39}
40
41#[derive(Debug, Clone)]
42pub struct DeckSelection {
43 pub name: String,
44 pub deck: Deck,
45 pub commander_name: Option<String>,
46}
47
48#[derive(Debug, Deserialize)]
49struct PresetDeckFile {
50 label: String,
51 #[serde(default)]
52 commander: Option<String>,
53 cards: Vec<PresetDeckCard>,
54}
55
56#[derive(Debug, Deserialize)]
57struct PresetDeckCard {
58 name: String,
59 count: usize,
60 #[serde(default)]
61 set: String,
62}
63
64impl Config {
65 pub fn from_env() -> Self {
66 let username = format!("self-hosted-node-{}", uuid::Uuid::new_v4());
67 let bot_username = env_first("SELF_HOSTED_NODE_BOT_USERNAME", "FORGE_ROOM_BOT_USERNAME")
68 .unwrap_or_else(|| format!("{username}-bot"));
69 let host_deck_id = env_first("SELF_HOSTED_NODE_DECK", "FORGE_ROOM_NODE_DECK")
70 .unwrap_or_else(|| "ashling_limitless_commander".into());
71 let bot_deck_id = env_first("SELF_HOSTED_NODE_BOT_DECK", "FORGE_ROOM_BOT_DECK")
72 .unwrap_or_else(|| "neheb_minotaur_commander".into());
73 let host_commander = env_first("SELF_HOSTED_NODE_COMMANDER", "FORGE_ROOM_NODE_COMMANDER")
74 .filter(|value| !value.is_empty())
75 .or_else(|| infer_commander_name(&host_deck_id).map(str::to_string));
76 let bot_commander = env_first("SELF_HOSTED_NODE_BOT_COMMANDER", "FORGE_ROOM_BOT_COMMANDER")
77 .filter(|value| !value.is_empty())
78 .or_else(|| infer_commander_name(&bot_deck_id).map(str::to_string));
79
80 let room_id = env_first("SELF_HOSTED_NODE_ROOM_ID", "FORGE_ROOM_ID")
81 .filter(|value| !value.is_empty());
82 let engine_enabled_default = room_id.is_none();
83
84 let format = env_first("SELF_HOSTED_NODE_FORMAT", "FORGE_ROOM_FORMAT")
85 .and_then(|value| parse_format(&value))
86 .unwrap_or(GameFormat::Any);
87 Self {
88 backend: EngineBackendKind::from_env(),
89 relay_url: env_first("SELF_HOSTED_NODE_RELAY_URL", "FORGE_RELAY_URL")
90 .unwrap_or_else(|| "ws://127.0.0.1:9443".to_string()),
91 username,
92 password: env_first("SELF_HOSTED_NODE_SERVER_KEY", "MANABREW_SERVER_KEY")
93 .unwrap_or_else(|| "forge".to_string()),
94 room_id,
95 room_name: env_first("SELF_HOSTED_NODE_ROOM_NAME", "FORGE_ROOM_NAME")
96 .unwrap_or_else(|| "Self-Hosted Node".into()),
97 max_players: env_first("SELF_HOSTED_NODE_MAX_PLAYERS", "FORGE_ROOM_MAX_PLAYERS")
98 .and_then(|value| value.parse().ok())
99 .unwrap_or(4),
100 state_delta: env_bool(
104 "SELF_HOSTED_NODE_STATE_DELTA",
105 "FORGE_ROOM_STATE_DELTA",
106 true,
107 ),
108 max_games: env_first("SELF_HOSTED_NODE_MAX_GAMES", "FORGE_ROOM_MAX_GAMES")
109 .and_then(|value| value.parse().ok())
110 .filter(|games| *games >= 1)
111 .unwrap_or(1),
112 format,
113 auto_start: env_bool(
114 "SELF_HOSTED_NODE_AUTO_START",
115 "FORGE_ROOM_AUTO_START",
116 false,
117 ),
118 engine_enabled: env_bool(
119 "SELF_HOSTED_NODE_ENGINE_ENABLED",
120 "FORGE_ROOM_ENGINE_ENABLED",
121 engine_enabled_default,
122 ),
123 host_plays: env_bool(
124 "SELF_HOSTED_NODE_HOST_PLAYS",
125 "FORGE_ROOM_NODE_HOST_PLAYS",
126 false,
127 ),
128 official_key: arg_value("--official")
129 .or_else(|| env_first("SELF_HOSTED_NODE_OFFICIAL_KEY", "SECRET_MANABREW_KEY"))
130 .filter(|value| !value.is_empty()),
131 room_password: arg_value("--password")
132 .or_else(|| env_first("SELF_HOSTED_NODE_ROOM_PASSWORD", "FORGE_ROOM_PASSWORD"))
133 .filter(|value| !value.is_empty()),
134 bot_enabled: env_bool(
135 "SELF_HOSTED_NODE_BOT_ENABLED",
136 "FORGE_ROOM_BOT_ENABLED",
137 false,
138 ),
139 bot_username,
140 forge_ai: env_bool("SELF_HOSTED_NODE_FORGE_AI", "FORGE_ROOM_FORGE_AI", false),
141 bot_local_answers: env_bool("SELF_HOSTED_NODE_BOT_LOCAL_ANSWERS", "", true),
142 reconnect_timeout_s: env_first("SELF_HOSTED_NODE_RECONNECT_TIMEOUT_S", "")
143 .and_then(|value| value.parse().ok()),
144 table_style: None,
145 host_deck: load_deck_selection(&host_deck_id, host_commander),
146 bot_deck: load_deck_selection(&bot_deck_id, bot_commander),
147 }
148 }
149
150 #[allow(clippy::too_many_arguments)]
155 pub fn for_hosted_room(
156 relay_url: String,
157 password: String,
158 room_name: String,
159 format: GameFormat,
160 max_players: u8,
161 room_password: Option<String>,
162 reconnect_timeout_s: Option<u32>,
163 table_style: Option<String>,
164 ) -> Self {
165 let username = format!("forge-host-{}", uuid::Uuid::new_v4());
166 let bot_username = format!("{username}-bot");
167 Self {
168 backend: EngineBackendKind::Forge,
169 relay_url,
170 username,
171 password,
172 room_id: None,
173 room_name,
174 max_players,
175 max_games: 1,
176 state_delta: false,
177 format,
178 auto_start: false,
179 engine_enabled: true,
180 host_plays: false,
181 official_key: None,
182 room_password,
183 bot_enabled: false,
184 bot_username,
185 forge_ai: false,
186 bot_local_answers: true,
187 reconnect_timeout_s,
188 table_style,
189 host_deck: synthetic_deck("forge-host", None),
190 bot_deck: synthetic_deck("forge-bot", None),
191 }
192 }
193}
194
195#[derive(Debug, Clone)]
196pub struct SelfPlayConfig {
197 pub seats: Vec<DeckSelection>,
198 pub starting_life: i32,
199 pub seed: u64,
200}
201
202impl SelfPlayConfig {
203 pub fn from_env() -> Self {
204 let seed = env::var("SELF_HOSTED_NODE_SELF_PLAY_SEED")
205 .ok()
206 .and_then(|value| value.parse().ok())
207 .unwrap_or(42);
208 let starting_life = match env::var("SELF_HOSTED_NODE_SELF_PLAY_FORMAT")
209 .ok()
210 .and_then(|value| parse_format(&value))
211 {
212 Some(GameFormat::Commander) => 40,
213 _ => 20,
214 };
215
216 let base: Vec<DeckSelection> = match env::var("SELF_HOSTED_NODE_SELF_PLAY_DECKS") {
217 Ok(ids) if !ids.trim().is_empty() => ids
218 .split(',')
219 .map(str::trim)
220 .filter(|id| !id.is_empty())
221 .map(|id| load_deck_selection(id, infer_commander_name(id).map(str::to_string)))
222 .collect(),
223 _ => default_self_play_seats(),
224 };
225
226 let players = env::var("SELF_HOSTED_NODE_SELF_PLAY_PLAYERS")
227 .ok()
228 .and_then(|value| value.parse().ok())
229 .unwrap_or(base.len())
230 .max(2);
231 let seats = (0..players).map(|i| base[i % base.len()].clone()).collect();
232 Self {
233 seats,
234 starting_life,
235 seed,
236 }
237 }
238}
239
240fn default_self_play_seats() -> Vec<DeckSelection> {
241 vec![
242 mono_seat("Mountain", "Lightning Bolt"),
243 mono_seat("Forest", "Grizzly Bears"),
244 ]
245}
246
247fn mono_seat(land: &str, spell: &str) -> DeckSelection {
248 let card = |name: &str| DeckCard {
249 identity: DeckCardIdentity {
250 name: name.to_string(),
251 ..Default::default()
252 },
253 ..Default::default()
254 };
255 let name = format!("{land} / {spell}");
256 DeckSelection {
257 deck: Deck {
258 name: name.clone(),
259 cards: (0..24)
260 .map(|_| card(land))
261 .chain((0..36).map(|_| card(spell)))
262 .collect(),
263 ..Default::default()
264 },
265 name,
266 commander_name: None,
267 }
268}
269
270pub fn workspace_root() -> PathBuf {
271 Path::new(env!("CARGO_MANIFEST_DIR"))
272 .ancestors()
273 .nth(3)
274 .unwrap_or_else(|| Path::new("."))
275 .to_path_buf()
276}
277
278fn load_deck_selection(deck_id: &str, commander_name: Option<String>) -> DeckSelection {
279 match load_preset_deck(deck_id, commander_name.clone()) {
280 Ok(deck) => deck,
281 Err(error) => {
282 warn!(deck_id, %error, "falling back to synthetic self-hosted-node deck");
283 synthetic_deck(deck_id, commander_name)
284 }
285 }
286}
287
288fn load_preset_deck(
289 deck_id: &str,
290 commander_name: Option<String>,
291) -> Result<DeckSelection, Box<dyn std::error::Error + Send + Sync>> {
292 let path = preset_decks_dir().join(format!("{deck_id}.json"));
293 let contents = std::fs::read_to_string(&path)?;
294 let preset: PresetDeckFile = serde_json::from_str(&contents)?;
295 let mut cards = Vec::new();
296 for entry in preset.cards {
297 for _ in 0..entry.count {
298 cards.push(DeckCard {
299 identity: DeckCardIdentity {
300 name: entry.name.clone(),
301 set_code: entry.set.clone(),
302 ..Default::default()
303 },
304 ..Default::default()
305 });
306 }
307 }
308 let label = preset.label;
309 Ok(DeckSelection {
310 deck: Deck {
311 name: label.clone(),
312 cards,
313 ..Default::default()
314 },
315 name: label,
316 commander_name: commander_name.or(preset.commander),
317 })
318}
319
320fn preset_decks_dir() -> PathBuf {
321 env::var("PRESET_DECKS_DIR")
322 .map(PathBuf::from)
323 .unwrap_or_else(|_| workspace_root().join("public/preset_decks"))
324}
325
326fn synthetic_deck(name: &str, commander_name: Option<String>) -> DeckSelection {
327 let cards: Vec<DeckCard> = (0..60)
328 .map(|_| DeckCard {
329 identity: DeckCardIdentity {
330 name: "Mountain".to_string(),
331 set_code: "M20".to_string(),
332 ..Default::default()
333 },
334 ..Default::default()
335 })
336 .collect();
337 DeckSelection {
338 name: name.to_string(),
339 deck: Deck {
340 name: name.to_string(),
341 cards,
342 ..Default::default()
343 },
344 commander_name,
345 }
346}
347
348fn infer_commander_name(deck_id: &str) -> Option<&'static str> {
349 match deck_id {
350 "ashling_limitless_commander" => Some("Ashling, the Limitless"),
351 "hearthhull_world_shaper_commander" => Some("Hearthhull, the Worldseed"),
352 "kaalia_regression_commander" => Some("Kaalia of the Vast"),
353 "neheb_minotaur_commander" => Some("Neheb, the Worthy"),
354 "ramses_regression_commander" => Some("Ramses, Assassin Lord"),
355 "real_teval_commander" => None,
356 _ => None,
357 }
358}
359
360fn env_first(primary: &str, fallback: &str) -> Option<String> {
361 env::var(primary).ok().or_else(|| env::var(fallback).ok())
362}
363
364fn arg_value(flag: &str) -> Option<String> {
365 let prefix = format!("{flag}=");
366 let mut args = env::args();
367 while let Some(arg) = args.next() {
368 if let Some(value) = arg.strip_prefix(&prefix) {
369 return Some(value.to_string());
370 }
371 if arg == flag {
372 return args.next();
373 }
374 }
375 None
376}
377
378fn env_bool(primary: &str, fallback: &str, default: bool) -> bool {
379 env_first(primary, fallback)
380 .and_then(|value| match value.to_ascii_lowercase().as_str() {
381 "1" | "true" | "yes" | "on" => Some(true),
382 "0" | "false" | "no" | "off" => Some(false),
383 _ => None,
384 })
385 .unwrap_or(default)
386}
387
388fn parse_format(value: &str) -> Option<GameFormat> {
389 let mut chars = value.trim().chars();
390 let first = chars.next()?;
391 let mut canonical = first.to_ascii_uppercase().to_string();
392 canonical.extend(chars.map(|c| c.to_ascii_lowercase()));
393 serde_json::from_value(serde_json::Value::String(canonical)).ok()
394}