Skip to main content

dotzuki_runner/
manifest.rs

1//! Serde model of `.dotzuki-editor.json`, the single manifest of a zero-Rust
2//! game project. See `docs/reference/project-manifest.md` for the full
3//! contract.
4//!
5//! Reads are lenient (unknown keys ignored, everything optional except the
6//! fields every consumer needs) so old editor-written projects keep parsing.
7
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13/// Root-relative scene directory used when the manifest has no `game` section.
14pub const DEFAULT_SCENES_DIR: &str = "assets/scenes";
15
16/// Story-activity scenesDir fallback, mirroring the editor's SCENE_DEFAULT_DIR.
17pub const DEFAULT_STORY_SCENES_DIR: &str = "maps";
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Manifest {
21    /// Display name (not necessarily a slug).
22    pub name: String,
23    /// Game data root, relative to the project dir (e.g. "./data").
24    #[serde(rename = "dataRoot")]
25    pub data_root: String,
26    /// Graphics root, relative to the project dir (e.g. "./gfx").
27    #[serde(rename = "gfxRoot", default, skip_serializing_if = "Option::is_none")]
28    pub gfx_root: Option<String>,
29    #[serde(default)]
30    pub activities: Vec<Activity>,
31    /// Optional engine-facing section; absent in older editor projects.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub game: Option<GameSection>,
34    /// Optional battle-system section; absent in projects without battles.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub battle: Option<BattleSection>,
37    /// Optional shop/currency section; absent ⇒ the defaults below apply the
38    /// moment money is first needed (a shop opens or the Bag shows money).
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub shop: Option<ShopSection>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Activity {
45    pub id: String,
46    #[serde(rename = "type")]
47    pub kind: String,
48    #[serde(default)]
49    pub label: String,
50    #[serde(default)]
51    pub icon: String,
52    #[serde(default)]
53    pub enabled: bool,
54    /// Free-form per-type config (see the spec for the known keys).
55    #[serde(default)]
56    pub config: serde_json::Value,
57}
58
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct GameSection {
61    /// Stem of the `.scene` file under `scenesDir` the game boots into.
62    #[serde(rename = "entryScene", default, skip_serializing_if = "Option::is_none")]
63    pub entry_scene: Option<String>,
64    /// Map to spawn on (engine-specific; no default).
65    #[serde(rename = "entryMap", default, skip_serializing_if = "Option::is_none")]
66    pub entry_map: Option<String>,
67    /// Root-relative directory of story scenes (default: "assets/scenes").
68    #[serde(rename = "scenesDir", default, skip_serializing_if = "Option::is_none")]
69    pub scenes_dir: Option<String>,
70}
71
72// ── battle section ──────────────────────────────────────────────────────────
73
74/// Default record field holding a combatant's skill id list.
75pub const DEFAULT_SKILLS_FIELD: &str = "skills";
76/// Default skill-record field holding the skill category.
77pub const DEFAULT_CATEGORY_FIELD: &str = "type";
78/// Default skill-record field holding the resource (MP) cost.
79pub const DEFAULT_COST_FIELD: &str = "mpCost";
80/// Default rules file (project-root-relative), used when `battle.rules` is
81/// absent; parsed only when the file exists.
82pub const DEFAULT_RULES_FILE: &str = "data/rules.ron";
83
84/// Default record field making an item battle-usable (heal amount).
85pub const DEFAULT_HEAL_FIELD: &str = "healHp";
86
87/// The optional top-level `battle` section: how project data tables map onto
88/// the generic battle system. Every key is optional; the defaults match
89/// the documented schema (see `docs/reference/project-manifest.md`).
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct BattleSection {
92    /// The player's party table (`{ "table": "<id>" }`); ALL records of the
93    /// table form the party (sorted by record id), with switching in battle.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub party: Option<BattleTableRef>,
96    /// The enemy table; `startBattle("<id>")` names a record in it (a single
97    /// wild enemy) when the id is not an encounter record.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub enemies: Option<BattleTableRef>,
100    /// The encounters table (enemy parties + trainer battles): when set AND
101    /// `startBattle("<id>")` names a record in it, the battle runs against
102    /// the encounter's ordered enemy list (a queue), with its trainer flag
103    /// and money reward. Absent ⇒ every battle is a single wild enemy.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub encounters: Option<BattleTableRef>,
106    /// The skills table + field-name mapping.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub skills: Option<BattleSkills>,
109    /// Stat field mapping: stat role → record field name.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub stats: Option<BattleStats>,
112    /// Record field holding the combatant's resource pool (e.g. `"mp"`);
113    /// absent ⇒ no resource gate (every skill is free).
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub resource: Option<String>,
116    /// Project-root-relative rules file (dotzuki-rules `Ruleset` RON). Only the
117    /// `type_chart` is consumed in v1. Default: `data/rules.ron` (used only
118    /// when the file exists).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub rules: Option<String>,
121    /// Battle-usable items (v2-b); absent ⇒ no Item menu in battle.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub items: Option<BattleItems>,
124    /// EXP/level growth (v2-c); absent ⇒ no EXP is earned and records'
125    /// `level` fields only feed RON level-ops (v1 behavior, unchanged).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub levels: Option<BattleLevels>,
128}
129
130/// The battle items block: the items table, which record field makes an item
131/// battle-usable (a positive heal amount), and the starting inventory.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct BattleItems {
134    /// Table id of the items table.
135    pub table: String,
136    /// Item record field holding the heal amount; an item is battle-usable
137    /// iff its record has a positive number there (default `"healHp"`).
138    /// Free-text `effect` fields are display-only.
139    #[serde(rename = "healField", default = "default_heal_field")]
140    pub heal_field: String,
141    /// The starting inventory (record id → count), applied at first boot.
142    #[serde(default)]
143    pub starting: HashMap<String, u32>,
144}
145
146fn default_heal_field() -> String {
147    DEFAULT_HEAL_FIELD.to_string()
148}
149
150// ── levels (EXP / stat growth) ────────────────────────────────────────────────
151
152/// Default enemy-record field holding the EXP reward.
153pub const DEFAULT_EXP_FIELD: &str = "exp";
154/// Default combatant-record field holding its starting level.
155pub const DEFAULT_LEVEL_FIELD: &str = "level";
156/// Default stat growth per level above 1 (+5%).
157pub const DEFAULT_GROWTH: f64 = 0.05;
158/// Default level cap.
159pub const DEFAULT_MAX_LEVEL: u32 = 100;
160/// Default exp-curve base (`exp_to_next(L) = base × L^exponent`).
161pub const DEFAULT_CURVE_BASE: u32 = 8;
162/// Default exp-curve exponent.
163pub const DEFAULT_CURVE_EXPONENT: u32 = 3;
164
165/// The battle `levels` block (v2-c): EXP rewards and level growth. Every key
166/// is optional (the defaults shown in the spec); an absent block keeps the v1
167/// behavior exactly — no EXP is earned, stats never grow, and a record's
168/// `level` field only feeds level-based RON ops/predicates.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct BattleLevels {
171    /// ENEMY record field holding the EXP reward (0 when absent on a record).
172    #[serde(rename = "expField", default = "default_exp_field")]
173    pub exp_field: String,
174    /// Combatant record field holding its starting level (default 1).
175    #[serde(rename = "levelField", default = "default_level_field")]
176    pub level_field: String,
177    /// The exp curve (`exp_to_next(L) = base × L^exponent`, integer).
178    #[serde(default)]
179    pub curve: ExpCurve,
180    /// Stat growth per level above 1: effective stat =
181    /// `floor(raw × (1 + growth × (level − 1)))`.
182    #[serde(default = "default_growth")]
183    pub growth: f64,
184    /// Level cap (level-ups stop here; EXP keeps accumulating).
185    #[serde(rename = "maxLevel", default = "default_max_level")]
186    pub max_level: u32,
187}
188
189impl Default for BattleLevels {
190    fn default() -> Self {
191        Self {
192            exp_field: default_exp_field(),
193            level_field: default_level_field(),
194            curve: ExpCurve::default(),
195            growth: DEFAULT_GROWTH,
196            max_level: DEFAULT_MAX_LEVEL,
197        }
198    }
199}
200
201/// The exp curve: `exp_to_next(L) = base × L^exponent` (integer).
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ExpCurve {
204    #[serde(default = "default_curve_base")]
205    pub base: u32,
206    #[serde(default = "default_curve_exponent")]
207    pub exponent: u32,
208}
209
210impl Default for ExpCurve {
211    fn default() -> Self {
212        Self {
213            base: DEFAULT_CURVE_BASE,
214            exponent: DEFAULT_CURVE_EXPONENT,
215        }
216    }
217}
218
219fn default_exp_field() -> String {
220    DEFAULT_EXP_FIELD.to_string()
221}
222fn default_level_field() -> String {
223    DEFAULT_LEVEL_FIELD.to_string()
224}
225fn default_growth() -> f64 {
226    DEFAULT_GROWTH
227}
228fn default_max_level() -> u32 {
229    DEFAULT_MAX_LEVEL
230}
231fn default_curve_base() -> u32 {
232    DEFAULT_CURVE_BASE
233}
234fn default_curve_exponent() -> u32 {
235    DEFAULT_CURVE_EXPONENT
236}
237
238/// A `{ "table": "<id>" }` reference to a data table of the data activity.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct BattleTableRef {
241    /// Table id (matched against the data activity's `config.tables[].id`).
242    pub table: String,
243}
244
245/// The skills table reference + the record field names the battle reads.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct BattleSkills {
248    /// Table id of the skills table.
249    pub table: String,
250    /// Combatant record field listing skill ids (default `"skills"`).
251    #[serde(default = "default_skills_field")]
252    pub field: String,
253    /// Skill record field holding the category (default `"type"`).
254    #[serde(rename = "categoryField", default = "default_category_field")]
255    pub category_field: String,
256    /// Skill record field holding the resource cost (default `"mpCost"`).
257    #[serde(rename = "costField", default = "default_cost_field")]
258    pub cost_field: String,
259}
260
261fn default_skills_field() -> String {
262    DEFAULT_SKILLS_FIELD.to_string()
263}
264fn default_category_field() -> String {
265    DEFAULT_CATEGORY_FIELD.to_string()
266}
267fn default_cost_field() -> String {
268    DEFAULT_COST_FIELD.to_string()
269}
270
271/// Stat role → record field name. Defaults: hp/atk/def/spd.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct BattleStats {
274    #[serde(default = "default_hp_field")]
275    pub hp: String,
276    #[serde(default = "default_atk_field")]
277    pub attack: String,
278    #[serde(default = "default_def_field")]
279    pub defense: String,
280    #[serde(default = "default_spd_field")]
281    pub speed: String,
282}
283
284impl Default for BattleStats {
285    fn default() -> Self {
286        Self {
287            hp: default_hp_field(),
288            attack: default_atk_field(),
289            defense: default_def_field(),
290            speed: default_spd_field(),
291        }
292    }
293}
294
295fn default_hp_field() -> String {
296    "hp".to_string()
297}
298fn default_atk_field() -> String {
299    "atk".to_string()
300}
301fn default_def_field() -> String {
302    "def".to_string()
303}
304fn default_spd_field() -> String {
305    "spd".to_string()
306}
307
308// ── shop section ────────────────────────────────────────────────────────────
309
310/// Default currency label when the manifest has no `shop` section.
311pub const DEFAULT_CURRENCY: &str = "G";
312/// Default starting money when the manifest has no `shop` section.
313pub const DEFAULT_START_MONEY: u32 = 100;
314
315/// The optional top-level `shop` section: the currency label and the money
316/// the player starts with. Both keys optional; the defaults are `G` / `100`.
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct ShopSection {
319    /// Currency label shown next to amounts (shop UI, Bag). Default `"G"`.
320    #[serde(default = "default_currency")]
321    pub currency: String,
322    /// Money a fresh game starts with. Default `100`.
323    #[serde(rename = "startMoney", default = "default_start_money")]
324    pub start_money: u32,
325}
326
327fn default_currency() -> String {
328    DEFAULT_CURRENCY.to_string()
329}
330fn default_start_money() -> u32 {
331    DEFAULT_START_MONEY
332}
333
334/// A data-table definition lifted from the data activity's `config.tables[]`
335/// (`{ id, dir, fields[] }`; field entries may be strings or objects — the
336/// editor writes `{ "key": …, "type": … }`, hand-grown projects may use
337/// `{ "id": … }`).
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct TableDef {
340    /// Table id (what the battle section's `table` values name).
341    pub id: String,
342    /// Records directory, relative to `dataRoot` (`<dir>/<record>.json`).
343    pub dir: String,
344    /// Field ids declared by the table schema.
345    pub fields: Vec<String>,
346}
347
348impl Manifest {
349    /// Every data table declared by the `data` activity (`config.tables[]`).
350    pub fn data_tables(&self) -> Vec<TableDef> {
351        let mut out = Vec::new();
352        for activity in &self.activities {
353            if activity.kind != "data" {
354                continue;
355            }
356            let Some(tables) = activity.config.get("tables").and_then(|v| v.as_array()) else {
357                continue;
358            };
359            for table in tables {
360                let (Some(id), Some(dir)) = (
361                    table.get("id").and_then(|v| v.as_str()),
362                    table.get("dir").and_then(|v| v.as_str()),
363                ) else {
364                    continue;
365                };
366                let fields = table
367                    .get("fields")
368                    .and_then(|v| v.as_array())
369                    .map(|fs| {
370                        fs.iter()
371                            .filter_map(|f| {
372                                f.as_str().map(str::to_string).or_else(|| {
373                                    // Editor schemas use `key`; some projects use `id`.
374                                    f.get("key")
375                                        .or_else(|| f.get("id"))
376                                        .and_then(|v| v.as_str())
377                                        .map(str::to_string)
378                                })
379                            })
380                            .collect()
381                    })
382                    .unwrap_or_default();
383                out.push(TableDef {
384                    id: id.to_string(),
385                    dir: dir.to_string(),
386                    fields,
387                });
388            }
389        }
390        out
391    }
392
393    /// The data table with id `table_id`, if declared.
394    pub fn data_table(&self, table_id: &str) -> Option<TableDef> {
395        self.data_tables().into_iter().find(|t| t.id == table_id)
396    }
397}
398
399impl Manifest {
400    /// Scene directory: `game.scenesDir`, or the default when absent.
401    pub fn scenes_dir(&self) -> &str {
402        self.game
403            .as_ref()
404            .and_then(|g| g.scenes_dir.as_deref())
405            .unwrap_or(DEFAULT_SCENES_DIR)
406    }
407
408    /// Every directory that may hold DSL files, resolved against `root`:
409    /// the [`dsl_dirs_rel`](Self::dsl_dirs_rel) paths joined onto `root`.
410    ///
411    /// Missing directories are kept in the list; callers decide how to treat
412    /// them (`compile_dirs` silently skips them).
413    pub fn dsl_dirs(&self, root: &Path) -> Vec<PathBuf> {
414        self.dsl_dirs_rel().iter().map(|d| root.join(d)).collect()
415    }
416
417    /// Every directory that may hold DSL files, as project-relative POSIX
418    /// paths (the VFS form of [`dsl_dirs`](Self::dsl_dirs)):
419    ///
420    /// - the scene directory (`game.scenesDir`, root-relative);
421    /// - each `script` activity's `scriptsDir` (dataRoot-relative);
422    /// - each `story` activity's `scenesDir` (dataRoot-relative, default "maps");
423    /// - each `ui` activity's `guiRoot` (root-relative).
424    pub fn dsl_dirs_rel(&self) -> Vec<String> {
425        let data_root = crate::vfs::join_path("", &self.data_root);
426        let mut dirs = vec![crate::vfs::join_path("", self.scenes_dir())];
427        for activity in &self.activities {
428            match activity.kind.as_str() {
429                "script" => {
430                    if let Some(dir) = config_str(&activity.config, "scriptsDir") {
431                        dirs.push(crate::vfs::join_path(&data_root, dir));
432                    }
433                }
434                "story" => {
435                    let dir = config_str(&activity.config, "scenesDir")
436                        .unwrap_or(DEFAULT_STORY_SCENES_DIR);
437                    dirs.push(crate::vfs::join_path(&data_root, dir));
438                }
439                "ui" => {
440                    if let Some(dir) = config_str(&activity.config, "guiRoot") {
441                        dirs.push(crate::vfs::join_path("", dir));
442                    }
443                }
444                _ => {}
445            }
446        }
447        let mut seen = HashSet::new();
448        dirs.retain(|d| seen.insert(d.clone()));
449        dirs
450    }
451}
452
453fn config_str<'a>(config: &'a serde_json::Value, key: &str) -> Option<&'a str> {
454    config.get(key).and_then(|v| v.as_str())
455}