bevy_grimoire_core 0.6.2

Engine-free world-building core: constraint IR, solvers, WFC, deterministic RNG.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! **The reload contract: every kind of file the editor writes (and the hand-authored files it reads),
//! and what a running program does when one changes on disk.**
//!
//! Whether a running game or editor picks a file up, and how, used to be decided case by case: a
//! kind could ship with no reload path at all and nobody noticed (#198, series H1 to H7). This is the
//! one list. Each [`FileKind`] has a [`ReloadClass`] (what a change costs) and a [`Lifetime`] (what the
//! file belongs to), and two ratchet tests keep the list honest:
//!
//! - `every_written_kind_has_a_reload_class` (mapper): an operation declares the kinds it writes
//!   ([`FileKind::id`]), and one naming a kind this list does not have fails.
//! - `the_restart_list_only_shrinks` (here): every [`ReloadClass::Restart`] kind is on a checked-in
//!   list, so the debt can shrink but never silently grow.
//!
//! `docs/guide/reload-contract.md` renders [`table`], one row per kind, and a test keeps it current.
//!
//! # What a class means
//!
//! The class is the cost of a change **for the programs this repository ships** (the editor, and the
//! game-side `GrimoireLivePlugin`), the dearest of them when they differ. A kind nothing holds in
//! memory (read fresh at each use) is [`ReloadClass::Live`]: there is nothing to reload.
//!
//! # A request for a kind that was never loaded
//!
//! [`reload_for`] is the one policy. A change to a kind the program never loaded is the outcome
//! [`Reload::NotLoaded`], reported and nothing else: **never an implicit load**, because a load in the
//! middle of play is a hitch, and a program that did not ask for the file has no state to reload.

use std::fmt::Write as _;

/// **What a change to this kind costs a running program.** Ordered cheapest first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum ReloadClass {
    /// Mutate in place: the new content replaces the old in memory, or nothing holds the old (the
    /// file is read fresh at each use).
    Live,
    /// Rebuild what was made from it: the entities, meshes or world derived from the file are
    /// dropped and made again, and the rest of the program carries on.
    Respawn,
    /// Cannot be hot: a running program that holds this kind keeps the old content until it is
    /// restarted. Every kind here is on the checked-in list the ratchet reads.
    Restart,
}

impl ReloadClass {
    /// The word the guide and the table use.
    pub fn word(self) -> &'static str {
        match self {
            ReloadClass::Live => "Live",
            ReloadClass::Respawn => "Respawn",
            ReloadClass::Restart => "Restart",
        }
    }
}

/// **What the file belongs to**, which decides how it reloads: a global file (a family settings file)
/// changes everything that reads it, a per-map one only its map.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Lifetime {
    /// One per project, kit, species or crate: it outlives every map.
    Global,
    /// One per map, named after it: it is loaded with its map and goes with it.
    PerMap,
}

impl Lifetime {
    pub fn word(self) -> &'static str {
        match self {
            Lifetime::Global => "global",
            Lifetime::PerMap => "per map",
        }
    }
}

/// **Where a kind's path pattern starts.**
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Base {
    /// The project's directory (`assets/grimoire`, the one holding `kits.ron`). A kit's own files
    /// (its library, policy, terrain, water, forest, trees and `data.ron`) are under `<kit>/`, the
    /// authoring kit's directory.
    Grimoire,
    /// The game's root, the directory holding `assets/`.
    Root,
}

/// **One kind of file the editor writes.**
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FileKind {
    /// The stable name an operation declares in `Operation::writes` and a test reads.
    pub id: &'static str,
    pub base: Base,
    /// Path under [`Self::base`]. `<name>` and `<kit>` each stand for one word of letters, digits and
    /// underscores (no `.` and no `/`); `<file>` for one file name of any characters. Empty for a kind
    /// with no fixed path: the files a game registers in `data.ron`. A kit's own files
    /// (`<kit>/library.ron`, `<kit>/terrain/...`) live in the authoring kit's directory.
    pub pattern: &'static str,
    /// The type it parses to.
    pub parses_to: &'static str,
    pub class: ReloadClass,
    pub lifetime: Lifetime,
    /// **Who writes it besides the operations the default build registers**, or empty when only those
    /// do: a person by hand, a screen, or an operation built only with a cargo feature. A kind no
    /// default-build operation writes must say who does, so the list cannot keep a kind nobody writes.
    pub other_writers: &'static str,
    /// How a change reaches a running program, and what is left unsaid by the class.
    pub note: &'static str,
}

/// One row of [`KINDS`], positional so the table reads as a table.
#[allow(clippy::too_many_arguments)]
const fn kind(
    id: &'static str,
    base: Base,
    pattern: &'static str,
    parses_to: &'static str,
    class: ReloadClass,
    lifetime: Lifetime,
    other_writers: &'static str,
    note: &'static str,
) -> FileKind {
    FileKind { id, base, pattern, parses_to, class, lifetime, other_writers, note }
}

use Base::{Grimoire, Root};
use Lifetime::{Global, PerMap};
use ReloadClass::{Live, Respawn, Restart};

/// **Every kind of file the editor writes, and the hand-authored files it reads.** One row each.
pub const KINDS: &[FileKind] = &[
    // ---- the project's own files ----
    kind("kits", Grimoire, "kits.ron", "Kits", Respawn, Global,
        "the project chooser (create, bind, unbind)",
        "The editor reopens the whole project; the game's `GrimoireLivePlugin` reloads it whole."),
    kind("vocab", Grimoire, "vocab.ron", "Vocabularies", Respawn, Global,
        "the project chooser (create), the vocabulary editor",
        "The editor re-resolves its libraries in place; the game reloads the project whole."),
    kind("library", Grimoire, "<kit>/library.ron", "Library", Respawn, Global,
        "the project chooser (create a kit)",
        "One per kit. The editor rereads the kit; the game rebuilds the maps that place a changed piece."),
    kind("policy", Grimoire, "<kit>/project.ron", "Policy", Respawn, Global,
        "the project chooser (create a kit, remove a policy), `policy::remove_patch`",
        "One per kit: its exclusions, patches and mounts. Reloads with the kit's library."),
    kind("rules", Grimoire, "rules.ron", "Rules", Respawn, Global, "",
        "The editor reopens the project; the game revalidates it against the library."),
    kind("compositions", Grimoire, "compositions.ron", "Compositions", Respawn, Global, "",
        "A stamp expands to rows, so the maps that stamp a changed composition are rebuilt."),
    kind("standards", Grimoire, "standards.ron", "Standards", Live, Global,
        "by hand",
        "Read fresh each time a kit is checked; the editor revalidates it when it changes."),
    kind("repetition", Grimoire, "repetition.ron", "RepetitionRules", Live, Global,
        "by hand",
        "Read fresh on each `repetition_vary` run; the editor revalidates it when it changes."),
    kind("checks", Grimoire, "checks.ron", "Checks", Live, Global,
        "by hand",
        "`Checks::for_project` reads it fresh on every run, so an edit takes effect at once."),
    kind("data_registry", Grimoire, "<kit>/data.ron", "DataFiles", Live, Global,
        "by hand",
        "The registry of a game's own tuning files, read at each `data_set`; nothing holds it."),
    kind("rigs", Grimoire, "rigs.ron", "Rigs", Restart, Global,
        "the animation tab",
        "Read when the animation tab loads (its first entry, and again after a door change) and held in between; an edit while it is held is reported as `needs_restart` (a file that does not read, as `refused`) in `grimoire_state.reloads`."),
    kind("oplog", Grimoire, "oplog.jsonl", "JSON lines", Live, Global,
        "the dispatcher appends a line for every operation",
        "Appended by the dispatcher and read by the log tools, the undo walk and the steps feed; nothing holds it. The editor looks at it when its size or modification time moves and says in `grimoire_state.reloads` whether every line reads: a line that does not is named (`line 3.seq`), the readers skip it and an undo refuses until it is repaired."),
    kind("reference_images", Root, "references/<file>", "image bytes", Live, Global,
        "the editor, when a person drops an image on a map",
        "Copied into the project, where `reference_set` lists it and `grimoire_state.references` says whether it is there; nothing holds it. The editor looks at each picture the open map lists when its size or modification time moves and says in `grimoire_state.reloads` whether it is still the picture its name says: `refused` with the file and the header field (`signature`, `format`, `header`, `width` or `height`) when it is not, `applied` once it is again."),
    // ---- a map's own files ----
    kind("map", Grimoire, "maps/<name>.map.ron", "Map", Respawn, PerMap,
        "the project chooser (create, settings, rename)",
        "The editor adopts it into the open map, or keeps what it had when it does not parse; the game respawns the placements that changed."),
    kind("notes", Grimoire, "maps/<name>.notes.ron", "Notes", Live, PerMap, "",
        "The editor rereads it when its modification time moves; the game has no reader."),
    kind("plan", Grimoire, "maps/<name>.plan.ron", "Plan", Live, PerMap, "",
        "Read on demand by `plan_set` and the plan view."),
    kind("references", Grimoire, "maps/<name>.references.ron", "References", Live, PerMap, "",
        "Read on demand by `reference_set` and the notes."),
    kind("relations", Grimoire, "maps/<name>.relations.ron", "Relations", Live, PerMap,
        "by hand",
        "Read on demand by `relations_solve`."),
    kind("terrain", Grimoire, "<kit>/terrain/<name>.ron", "Heightfield", Respawn, PerMap,
        "`terrain_preset` (only in a build with the elements feature)",
        "The editor redraws the ground when the file's modification time moves. A file that does not parse is refused with its field named, and what is drawn stays; with nothing drawn yet, no ground is drawn."),
    kind("water_plan", Grimoire, "<kit>/water/<name>.ron", "WaterPlan", Live, PerMap, "",
        "The plan `water_run` reads; nothing draws it directly."),
    kind("water_field", Grimoire, "<kit>/water/<name>.field.ron", "WaterField", Respawn, PerMap, "",
        "The editor redraws the water surface when the file's modification time moves. A refused file keeps the water drawn before it."),
    kind("volume", Grimoire, "<kit>/volume/<name>.ron", "Volume", Respawn, PerMap, "",
        "The editor rebuilds the carved ground when the file's modification time moves. A refused file keeps the ground drawn before it."),
    kind("forest", Grimoire, "<kit>/forest/<name>.ron", "Forest", Respawn, PerMap, "",
        "The editor respawns the trees when the file's modification time moves."),
    kind("people", Grimoire, "<kit>/people/<name>.ron", "People", Live, PerMap, "",
        "Read at each `people_run`; nothing holds it."),
    kind("scripts", Grimoire, "<kit>/scripts/<name>.ron", "Scripts", Live, PerMap, "",
        "The map's triggers, read when they are run; nothing holds it."),
    // ---- shared by maps ----
    kind("trees", Grimoire, "<kit>/trees/<name>.tree.ron", "TreeParams", Respawn, Global, "",
        "A species a forest uses: the editor regrows every tree of it when the file's modification time moves, and a file that does not read leaves them as the last good one drew them."),
    // ---- the game root's files ----
    kind("family", Root, "family/<name>.ron", "a family crate's settings", Live, Global, "",
        "One file per family crate: the editor applies it in place, polled every 0.1 s. The game-side `GrimoireLivePlugin` does not watch `<root>/family/`."),
    kind("family_list", Root, "family.ron", "Family", Live, Global, "by hand",
        "Which family crates the project uses, read when the editor needs the list; nothing holds it."),
    kind("editor_settings", Grimoire, "editor.ron", "EditorSettings", Live, Global, "by hand",
        "The project layer of the editor's settings, laid over the user's `<config_dir>/Grimoire/settings.ron` key by key. The editor looks at it while it runs and applies it in place; a file that does not read keeps the last good one and is refused in `grimoire_state.reloads`. The game has no reader."),
    kind("agent", Root, "agent.ron", "AgentSettings", Live, Global, "by hand",
        "The note agent's model and prompt, read each time a note is sent; nothing holds it."),
    kind("play", Root, "play.ron", "PlayConfig", Live, Global, "by hand",
        "How the editor launches the game, read each time `play` runs; nothing holds it."),
    kind("destructible_marks", Root, "destructible.ron", "Marks", Live, Global,
        "`destructible_set` (only in a build with the carnage feature)",
        "Read at each `destructible_set` and `destructible_hit`; nothing holds it. A file that does not read refuses both, naming the file and the field, and is left as it is."),
    kind("destructible_bake", Root, "destructible/<name>.bake.ron", "a baked fracture", Live, Global,
        "`destructible_set` and `destructible_hit` (only in a build with the carnage feature)",
        "A cache the next hit rebuilds when it is absent or stale (another mesh or other cut settings). A file that does not read at all is refused, naming the file and the field, and is left as it is: remove it to bake afresh."),
    kind("game_data", Root, "", "the game's own RON", Restart, Global, "",
        "Written by `data_set` into files the game owns. This repository ships no reader that applies them, so a running game is restarted unless it re-reads them itself. The editor looks at each file `data.ron` lists when its size or modification time moves and says in `grimoire_state.reloads` whether it still reads: `refused` with the file and the leaf when it does not (a bounded leaf missing, not a number or outside its bounds, or a file cut off), `needs_restart` when an edit leaves one that reads as other text than it first read, `applied` when it is back to that text."),
];

/// **The kind with this id.**
pub fn kind_by_id(id: &str) -> Option<&'static FileKind> {
    KINDS.iter().find(|k| k.id == id)
}

/// **Whether a path segment pattern matches one segment.** `<word>` stands for one run of letters,
/// digits and underscores: no `.` and no `/`, so `<name>.ron` never matches `town.field.ron`. `<file>`
/// is the one exception: any non-empty file name.
fn segment_matches(pattern: &str, segment: &str) -> bool {
    let Some(open) = pattern.find('<') else {
        return pattern == segment;
    };
    let Some(close) = pattern[open..].find('>').map(|c| c + open) else {
        return false;
    };
    let (head, tail) = (&pattern[..open], &pattern[close + 1..]);
    let Some(rest) = segment.strip_prefix(head).and_then(|s| s.strip_suffix(tail)) else {
        return false;
    };
    let any = &pattern[open..=close] == "<file>";
    !rest.is_empty() && (any || rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'))
}

impl FileKind {
    /// Whether `path`, written with `/` and relative to [`Self::base`], is a file of this kind.
    pub fn matches(&self, path: &str) -> bool {
        if self.pattern.is_empty() {
            return false;
        }
        let mut want = self.pattern.split('/');
        let mut have = path.split('/');
        loop {
            match (want.next(), have.next()) {
                (None, None) => return true,
                (Some(w), Some(h)) if segment_matches(w, h) => {}
                _ => return false,
            }
        }
    }
}

/// **The kind a changed file is**, given its path relative to `base`, or `None` for a file that is not
/// one of ours. Patterns are unambiguous (a test proves it), so at most one kind answers.
pub fn kind_of(base: Base, path: &str) -> Option<&'static FileKind> {
    KINDS.iter().find(|k| k.base == base && k.matches(path))
}

/// **What a running program does about a change to a kind.**
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Reload {
    /// Replace the content in place.
    InPlace,
    /// Rebuild what was derived from the file.
    Respawn,
    /// The program holds the kind and cannot take the change: say so, and keep the old content.
    NeedsRestart,
    /// The program never loaded this kind, so there is nothing to reload. **Reported, never an
    /// implicit load.**
    NotLoaded,
}

/// **The policy: what a change to `kind` means for a program that has (or has not) loaded it.**
pub fn reload_for(kind: &FileKind, loaded: bool) -> Reload {
    if !loaded {
        return Reload::NotLoaded;
    }
    match kind.class {
        Live => Reload::InPlace,
        Respawn => Reload::Respawn,
        Restart => Reload::NeedsRestart,
    }
}

/// **The kinds with this class**, for the ratchet and the guide.
pub fn kinds_of_class(class: ReloadClass) -> impl Iterator<Item = &'static FileKind> {
    KINDS.iter().filter(move |k| k.class == class)
}

/// **The guide's table**, one row per kind. `writers` names the operations that write a kind (the
/// registry's `Operation::writes`, which this crate cannot see), so the table is rendered from the
/// code and a test compares it with `docs/guide/reload-contract.md`.
pub fn table(writers: &dyn Fn(&str) -> Vec<String>) -> String {
    let mut out = String::new();
    out.push_str("| kind | path | parses to | class | lifetime | written by | how a change lands |\n");
    out.push_str("|---|---|---|---|---|---|---|\n");
    for k in KINDS {
        let base = match k.base {
            Base::Grimoire => "<grimoire>",
            Base::Root => "<root>",
        };
        let path = if k.pattern.is_empty() {
            "(any file registered in `data.ron`)".to_owned()
        } else {
            format!("`{base}/{}`", k.pattern)
        };
        let ops = writers(k.id);
        let mut by = if ops.is_empty() {
            String::new()
        } else {
            ops.iter().map(|o| format!("`{o}`")).collect::<Vec<_>>().join(", ")
        };
        if !k.other_writers.is_empty() {
            if !by.is_empty() {
                by.push_str("; also ");
            }
            by.push_str(k.other_writers);
        }
        let _ = writeln!(
            out,
            "| `{}` | {path} | {} | {} | {} | {} | {} |",
            k.id,
            k.parses_to,
            k.class.word(),
            k.lifetime.word(),
            by,
            k.note
        );
    }
    out
}

/// **One setting that always needs a type change and a rebuild** (#202, H5).
///
/// The hot-reload series moves what can be data out of Rust types, so a change to it reloads. What
/// cannot move is listed here, once, with the reason, so "why does this need a rebuild" has an answer
/// in one place and a setting nobody can reload is a fact on a list and not a surprise. These are not
/// file kinds, so [`ReloadClass::Restart`] does not hold them: a `Restart` kind is debt that a reload
/// path can pay, and none of these has a file to reload.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rebuild {
    /// A stable word for the entry.
    pub id: &'static str,
    /// What changes when it changes, and where it lives.
    pub what: &'static str,
    /// **Why a file cannot hold it.** The reason, not the symptom.
    pub reason: &'static str,
}

/// **What needs a rebuild, and why.** Short on purpose: an entry that gains a file form leaves this list
/// and joins [`KINDS`] in the commit that gives it a reload path.
pub const REBUILD: &[Rebuild] = &[
    Rebuild {
        id: "family_crates",
        what: "Which family crates a build of the editor can host: one cargo feature each.",
        reason: "A family crate is compiled in, as an optional dependency pinned by git revision. `family.ron` says which a project uses and reloads live, but a build without the crate cannot run it: the editor names the entry as a problem until it is built with the feature.",
    },
    Rebuild {
        id: "operations",
        what: "The editor's verbs: the registered operations, each a Rust type with its parameters, preview, undo and the kinds it writes.",
        reason: "An operation is code. A new verb, or a changed one, is a type change; the MCP tool list and the parity test are read from the registry, so they follow the next build and no earlier.",
    },
    Rebuild {
        id: "file_kinds",
        what: "The set of file kinds the editor reads and writes, and the Rust type each parses to (`reload::KINDS`).",
        reason: "A kind needs a type that reads it, a class and a reader that applies it. A file cannot teach a running program to read a file of a new shape.",
    },
    Rebuild {
        id: "format_shapes",
        what: "The fields of every saved format, and its version number.",
        reason: "A field is a field of a Rust type. Adding one with a default keeps old files reading (`tests/old_data` freezes them), and a change that cannot is a new version with a migration. Either way the type changes and the build with it.",
    },
    Rebuild {
        id: "algorithms",
        what: "The solvers: the constraint solver, wave function collapse, the relation solver and the sight and reach checks.",
        reason: "An algorithm is code. Its tuning (a radius, a weight, a seed) is data where a file already carries it, such as `repetition.ron` and `standards.ron`; the search itself is not.",
    },
    Rebuild {
        id: "editor_surface",
        what: "The editor's own panels, colours and key chords.",
        reason: "The panels are spawned by systems, the colours are constants and the chords are a table in code; no file reads them today. They could take a file form, and until one does a change is a rebuild.",
    },
    Rebuild {
        id: "game_systems",
        what: "A game's own systems, components and schedule, and the host that runs its scripts.",
        reason: "A trigger's script reloads (`scripts`), but the interpreter that runs it, the components it acts on and the order systems run in are Rust. Behaviour that can be a script or a row of data is moved there under #202.",
    },
];

/// **The guide's second table**, one row per entry of [`REBUILD`], rendered like [`table`] so a test can
/// compare it with `docs/guide/reload-contract.md`.
pub fn rebuild_table() -> String {
    let mut out = String::new();
    out.push_str("| setting | what | why a file cannot hold it |\n");
    out.push_str("|---|---|---|\n");
    for r in REBUILD {
        let _ = writeln!(out, "| `{}` | {} | {} |", r.id, r.what, r.reason);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_segment_pattern_matches_one_word() {
        assert!(segment_matches("<name>.map.ron", "town.map.ron"));
        assert!(!segment_matches("<name>.ron", "town.field.ron"));
        assert!(!segment_matches("<name>.map.ron", ".map.ron"));
        assert!(segment_matches("kits.ron", "kits.ron"));
        assert!(!segment_matches("kits.ron", "kits.ron.tmp"));
    }

    #[test]
    fn a_changed_path_is_classified_by_its_pattern() {
        let map = kind_of(Base::Grimoire, "maps/town.map.ron").expect("a map is a kind");
        assert_eq!((map.id, map.class, map.lifetime), ("map", Respawn, PerMap));
        assert_eq!(kind_of(Base::Grimoire, "site/water/lake.field.ron").map(|k| k.id), Some("water_field"));
        assert_eq!(kind_of(Base::Grimoire, "site/water/lake.ron").map(|k| k.id), Some("water_plan"));
        assert_eq!(kind_of(Base::Grimoire, "site/library.ron").map(|k| k.id), Some("library"));
        assert_eq!(kind_of(Base::Root, "references/Sketch v2.JPG").map(|k| k.id), Some("reference_images"));
        assert_eq!(kind_of(Base::Root, "family/weather.ron").map(|k| k.id), Some("family"));
        assert_eq!(kind_of(Base::Grimoire, "maps/town.map.ron.tmp"), None);
        assert_eq!(kind_of(Base::Grimoire, ".live/game.ron"), None);
    }

    #[test]
    fn a_request_for_a_kind_never_loaded_is_reported_not_loaded() {
        for k in KINDS {
            assert_eq!(reload_for(k, false), Reload::NotLoaded, "{} must not load itself", k.id);
        }
        let by = |id| reload_for(kind_by_id(id).unwrap(), true);
        assert_eq!(by("family"), Reload::InPlace);
        assert_eq!(by("map"), Reload::Respawn);
        assert_eq!(by("rigs"), Reload::NeedsRestart);
    }
}