Skip to main content

self_hosted_node/
config.rs

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    /// Send state patches instead of a full board per viewer. Off until every
22    /// installed client can apply them; see #696.
23    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            // On by default: the relay expands a patch back into a full state
98            // for any seat whose client cannot apply one, so an old client sees
99            // the same board it always did. Set to 0 to send full states.
100            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    /// Config for an embedded Forge room host (e.g. the Tauri desktop app):
146    /// reuse the caller's relay connection, host the engine without taking a
147    /// seat, no bot. Deck fields are placeholders — unused when `host_plays` and
148    /// `bot_enabled` are false.
149    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}