Skip to main content

self_hosted_node/
config.rs

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    /// Send state patches instead of a full board per viewer. Off until every
23    /// installed client can apply them; see #696.
24    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    /// Which agent this node's bot seats play with. One source of truth:
35    /// the seat's relay connection and the in-process responder that may
36    /// answer for it both read this, so they cannot drift apart.
37    pub bot_agent: AgentKind,
38    /// Answer this node's bot seats in-process instead of over the relay.
39    /// Only sound because the responder is built from `bot_agent` too.
40    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            // On by default: the relay expands a patch back into a full state
107            // for any seat whose client cannot apply one, so an old client sees
108            // the same board it always did. Set to 0 to send full states.
109            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", true),
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    /// Config for an embedded Forge room host (e.g. the Tauri desktop app):
158    /// reuse the caller's relay connection, host the engine without taking a
159    /// seat, no bot. Deck fields are placeholders — unused when `host_plays` and
160    /// `bot_enabled` are false.
161    #[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: true,
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: true,
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}