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