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