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