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}