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}