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