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