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