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    /// Answer this node's bot seats in-process instead of over the relay.
34    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            // On by default: the relay expands a patch back into a full state
101            // for any seat whose client cannot apply one, so an old client sees
102            // the same board it always did. Set to 0 to send full states.
103            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    /// Config for an embedded Forge room host (e.g. the Tauri desktop app):
151    /// reuse the caller's relay connection, host the engine without taking a
152    /// seat, no bot. Deck fields are placeholders — unused when `host_plays` and
153    /// `bot_enabled` are false.
154    #[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}