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