Skip to main content

dotzuki_rules/
source.rs

1//! Dual-mode rule sourcing (doc 11 §4.2, §6): ONE `rules.ron`, two access modes
2//! that produce the **same** runtime [`Ruleset`].
3//!
4//! * **RELEASE / baked** ([`RuleSource::baked`]) — the caller `include_str!`s the
5//!   canonical `rules.ron` so it is compiled into the binary; **zero file IO**,
6//!   parsed once. This is the default build (the `hot-reload` feature is OFF).
7//! * **DEV / hot-reload** ([`RuleSource::from_path`]) — read `rules.ron` from
8//!   disk at startup and (behind the `hot-reload` feature) watch it; [`poll_changed`]
9//!   signals a change so the game rebuilds the registry **between turns**.
10//!
11//! Per doc 11 §4.2, swapping the compiled registry (the [`EffectId`]→op-list map)
12//! is **safe mid-battle**: effects are addressed by `EffectId` and live state
13//! lives in the engine's `EffectState` arena, **not** in the data — so a reload
14//! replaces the *vocabulary*, never the *in-flight state*.
15//!
16//! This module is **game-agnostic**: it yields the engine-agnostic [`Ruleset`]
17//! (game binding ↔ `P::Stat`/`P::Type` happens later in
18//! [`CompiledRuleset::compile`](crate::CompiledRuleset::compile)). The decisive
19//! invariant — proved by tests — is **baked text and disk text parse to the same
20//! `Ruleset`**, so both modes drive an identical runtime ruleset.
21//!
22//! [`poll_changed`]: RuleSource::poll_changed
23//! [`EffectId`]: dotzuki_engine::battle::stack::EffectId
24
25use std::path::{Path, PathBuf};
26
27use crate::model::{LoadError, Ruleset};
28
29/// A source of rule text yielding a runtime [`Ruleset`], in one of two modes
30/// (doc 11 §4.2). Both modes call the **same** [`Ruleset::from_ron`], so a baked
31/// build and a disk build of the *same* `rules.ron` produce byte-identical
32/// rulesets — the dual-mode guarantee.
33pub enum RuleSource {
34    /// **Baked** (RELEASE): the canonical `rules.ron` text compiled into the
35    /// binary via the caller's `include_str!`. Zero file IO. The default build.
36    Baked {
37        /// The `include_str!`'d source text.
38        text: &'static str,
39    },
40    /// **Disk** (DEV): `rules.ron` read from a file path. With the `hot-reload`
41    /// feature a [`Watcher`](self::watch::Watcher) observes the file and
42    /// [`poll_changed`](RuleSource::poll_changed) signals edits so the registry
43    /// is rebuilt between turns.
44    Disk {
45        /// The on-disk `rules.ron` path.
46        path: PathBuf,
47        /// The optional file watcher (present only with the `hot-reload` feature
48        /// and a successful watch init).
49        #[cfg(feature = "hot-reload")]
50        watcher: Option<watch::Watcher>,
51    },
52}
53
54impl RuleSource {
55    /// A **baked** source over `include_str!`'d text (RELEASE; the default build).
56    /// The text is compiled into the binary; loading never touches the filesystem.
57    pub fn baked(text: &'static str) -> Self {
58        RuleSource::Baked { text }
59    }
60
61    /// A **disk** source over a `rules.ron` path (DEV). Without the `hot-reload`
62    /// feature this still reads the file at [`load`](RuleSource::load) time (it
63    /// just never watches it); with the feature it also starts a watcher so
64    /// [`poll_changed`](RuleSource::poll_changed) can signal edits.
65    pub fn from_path(path: impl Into<PathBuf>) -> Self {
66        let path = path.into();
67        #[cfg(feature = "hot-reload")]
68        {
69            let watcher = watch::Watcher::new(&path).ok();
70            RuleSource::Disk { path, watcher }
71        }
72        #[cfg(not(feature = "hot-reload"))]
73        {
74            RuleSource::Disk { path }
75        }
76    }
77
78    /// Read the current rule text + parse it into a [`Ruleset`]. The decisive
79    /// dual-mode invariant: baked text and disk text run through the **same**
80    /// [`Ruleset::from_ron`], so identical bytes ⇒ identical ruleset.
81    pub fn load(&self) -> Result<Ruleset, LoadError> {
82        match self {
83            RuleSource::Baked { text } => Ruleset::from_ron(text),
84            RuleSource::Disk { path, .. } => {
85                let text = read_to_string(path)?;
86                Ruleset::from_ron(&text)
87            }
88        }
89    }
90
91    /// Poll whether the on-disk `rules.ron` has changed since the last poll
92    /// (DEV / hot-reload). A `true` return is the game's cue to re-[`load`] and
93    /// rebuild the compiled registry **between turns** (safe mid-battle —
94    /// doc 11 §4.2). A **baked** source never changes ⇒ always `false`; a disk
95    /// source without the `hot-reload` feature also returns `false` (no watcher).
96    ///
97    /// [`load`]: RuleSource::load
98    pub fn poll_changed(&mut self) -> bool {
99        match self {
100            RuleSource::Baked { .. } => false,
101            #[cfg(feature = "hot-reload")]
102            RuleSource::Disk { watcher, .. } => {
103                watcher.as_mut().map(|w| w.poll_changed()).unwrap_or(false)
104            }
105            #[cfg(not(feature = "hot-reload"))]
106            RuleSource::Disk { .. } => false,
107        }
108    }
109
110    /// Whether this source can hot-reload (a disk source with a live watcher).
111    /// `false` for a baked source or a feature-off build. Useful for diagnostics.
112    pub fn is_hot_reloadable(&self) -> bool {
113        match self {
114            RuleSource::Baked { .. } => false,
115            #[cfg(feature = "hot-reload")]
116            RuleSource::Disk { watcher, .. } => watcher.is_some(),
117            #[cfg(not(feature = "hot-reload"))]
118            RuleSource::Disk { .. } => false,
119        }
120    }
121}
122
123/// Read a file to a string, mapping IO errors into the loader's [`LoadError::Ron`]
124/// channel (a missing/unreadable `rules.ron` is a load error, never a battle-time
125/// surprise — doc 11 §4.2).
126fn read_to_string(path: &Path) -> Result<String, LoadError> {
127    std::fs::read_to_string(path)
128        .map_err(|e| LoadError::Ron(format!("reading {}: {e}", path.display())))
129}
130
131/// The `notify`-based file watcher (DEV only; behind the `hot-reload` feature).
132/// Mirrors the existing `dotzuki-app` `AssetWatcher` pattern (notify v6, poll-based,
133/// `mpsc` drain) but scoped to a single `rules.ron`. It draws NO randomness,
134/// reads NO clock that affects draw order, and never touches the interpreter —
135/// it is a pure file-change signal feeding a between-turns rebuild.
136#[cfg(feature = "hot-reload")]
137pub mod watch {
138    use std::path::{Path, PathBuf};
139    use std::sync::mpsc;
140
141    use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher as _};
142
143    /// A poll-based single-file watcher for `rules.ron` (doc 11 §4.2). Call
144    /// [`poll_changed`](Watcher::poll_changed) each frame; it drains the notify
145    /// channel and returns `true` if the watched file was modified/created.
146    pub struct Watcher {
147        _watcher: RecommendedWatcher,
148        rx: mpsc::Receiver<Result<notify::Event, notify::Error>>,
149        target: PathBuf,
150    }
151
152    impl Watcher {
153        /// Start watching `rules.ron`. Watches the file's **parent directory**
154        /// (the robust pattern: editors often replace-on-save, which removes the
155        /// inode a direct file-watch holds), filtering events down to the target
156        /// path. Returns an error if the watcher cannot be initialized.
157        pub fn new(path: &Path) -> Result<Self, String> {
158            let target = path.to_path_buf();
159            let dir = target
160                .parent()
161                .filter(|p| !p.as_os_str().is_empty())
162                .map(|p| p.to_path_buf())
163                .unwrap_or_else(|| PathBuf::from("."));
164            let (tx, rx) = mpsc::channel();
165            let mut watcher = RecommendedWatcher::new(
166                move |res| {
167                    let _ = tx.send(res);
168                },
169                Config::default(),
170            )
171            .map_err(|e| format!("failed to create rules.ron watcher: {e}"))?;
172            watcher
173                .watch(&dir, RecursiveMode::NonRecursive)
174                .map_err(|e| format!("failed to watch {}: {e}", dir.display()))?;
175            Ok(Self {
176                _watcher: watcher,
177                rx,
178                target,
179            })
180        }
181
182        /// Drain pending notify events; return `true` if the watched `rules.ron`
183        /// was modified or created since the last poll. Deduplicated implicitly
184        /// (any matching event ⇒ one `true`). Pure: no RNG, no draw-order effect.
185        pub fn poll_changed(&mut self) -> bool {
186            let mut changed = false;
187            while let Ok(Ok(event)) = self.rx.try_recv() {
188                if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_))
189                    && event.paths.iter().any(|p| paths_match(p, &self.target))
190                {
191                    changed = true;
192                }
193            }
194            changed
195        }
196    }
197
198    /// Compare an event path against the target, tolerating symlink/`.`/`..`
199    /// differences by falling back to file-name equality when canonicalization
200    /// is unavailable.
201    fn paths_match(event_path: &Path, target: &Path) -> bool {
202        if event_path == target {
203            return true;
204        }
205        match (event_path.canonicalize(), target.canonicalize()) {
206            (Ok(a), Ok(b)) if a == b => true,
207            _ => event_path.file_name() == target.file_name(),
208        }
209    }
210}