From f3eccaf302cca94f7a1a2ebc4395b409227a6de2 Mon Sep 17 00:00:00 2001 From: Alex Jercan Date: Sun, 11 Oct 2026 01:15:45 +0300 Subject: [PATCH 01/22] Add authored open item catalog --- CHANGELOG.md | 12 +- assets/base/base.bundle.ron | 1 + assets/base/items/base.content.ron | 92 +++ .../scenarios/drill_autopilot.content.ron | 2 +- .../base/scenarios/drill_gunnery.content.ron | 2 +- .../base/scenarios/drill_momentum.content.ron | 2 +- assets/base/scenarios/drill_stop.content.ron | 2 +- assets/base/scenarios/menu_duel.content.ron | 4 +- assets/base/scenarios/open_world.content.ron | 8 +- assets/base/scenarios/tutorial.content.ron | 2 +- assets/mods/example/CHANGELOG.md | 6 + assets/mods/example/README.md | 4 +- assets/mods/example/example.bundle.ron | 9 +- assets/mods/example/example.content.ron | 26 +- crates/nova_assets/Cargo.toml | 3 + crates/nova_assets/src/items.rs | 318 +++++++++ crates/nova_assets/src/lib.rs | 1 + crates/nova_assets/src/merge.rs | 70 +- crates/nova_assets/src/plugin.rs | 7 +- crates/nova_assets/tests/example_scenario.rs | 235 ++++++- .../nova_assets/tests/mod_binary_resources.rs | 18 +- .../nova_authoring/src/base_content/items.rs | 131 ++++ crates/nova_authoring/src/base_content/mod.rs | 6 +- .../base_content/scenarios/main_menu/duel.rs | 2 +- .../src/base_content/scenarios/open_world.rs | 12 +- .../base_content/scenarios/tutorial/range.rs | 2 +- crates/nova_authoring/src/generation.rs | 27 +- crates/nova_authoring/src/lint_walk.rs | 160 ++++- .../nova_bench/scenarios/arsenal.content.ron | 2 +- .../docking_warship_tender.content.ron | 2 +- crates/nova_bench/scenarios/hunt.content.ron | 2 +- crates/nova_bench/scenarios/range.content.ron | 2 +- .../scenarios/slingshot.content.ron | 2 +- crates/nova_command/src/commands.rs | 22 +- crates/nova_console/Cargo.toml | 3 + crates/nova_console/src/cheats.rs | 88 +-- crates/nova_console/src/completion.rs | 8 + crates/nova_console/src/inspect.rs | 2 +- .../nova_core/tests/world_resume_refusal.rs | 19 +- crates/nova_editor/src/event.rs | 3 +- crates/nova_gameplay/Cargo.toml | 2 +- crates/nova_gameplay/src/inventory.rs | 575 ++++++++++------- crates/nova_gameplay/src/test_support.rs | 36 ++ crates/nova_hud/src/ammo_readout.rs | 38 +- crates/nova_hud/src/cargo_canister_chips.rs | 78 ++- crates/nova_hud/src/pickup_sight.rs | 14 +- crates/nova_hud/src/situation.rs | 11 +- crates/nova_interface/src/inventory/app.rs | 280 ++++---- crates/nova_interface/src/inventory/mod.rs | 20 +- crates/nova_interface/src/inventory/tests.rs | 256 +++++++- crates/nova_interface/src/ship/scene.rs | 10 +- crates/nova_interface/src/ship/sections.rs | 4 +- crates/nova_interface/src/ship/tests.rs | 85 ++- crates/nova_menu/Cargo.toml | 2 + crates/nova_menu/src/leave.rs | 9 +- crates/nova_menu/src/lib.rs | 10 +- crates/nova_menu/src/load_screen.rs | 159 ++++- crates/nova_menu/src/tests/leave.rs | 3 + crates/nova_menu/src/tests/load_screen.rs | 191 +++++- crates/nova_menu/src/tests/support.rs | 3 + crates/nova_modding/src/lib.rs | 12 + crates/nova_probe/Cargo.toml | 5 + .../nova_probe/src/capabilities/snapshot.rs | 47 +- crates/nova_scenario/src/actions/ship.rs | 4 +- crates/nova_scenario/src/actions/spawn.rs | 15 +- crates/nova_scenario/src/lint/scenario.rs | 605 +++++++++++++++--- crates/nova_scenario/src/lint/ship.rs | 90 ++- crates/nova_scenario/src/loader/lifecycle.rs | 19 +- crates/nova_scenario/src/mining.rs | 110 ++-- crates/nova_scenario/src/objects/asteroid.rs | 13 +- crates/nova_scenario/src/objects/beacon.rs | 8 +- crates/nova_scenario/src/objects/spaceship.rs | 61 +- crates/nova_scenario/src/tests/docked_helm.rs | 3 +- crates/nova_ship/src/input/targeting/radar.rs | 3 +- crates/nova_ship/src/sections/ammo.rs | 46 +- .../src/sections/cargo_intake_section.rs | 12 +- .../sections/cargo_intake_section/tests.rs | 87 ++- crates/nova_ship/src/sections/frozen.rs | 28 +- crates/nova_ship/src/sections/frozen_piece.rs | 1 + crates/nova_ship/src/sections/integrity.rs | 9 + .../src/sections/railgun_section/firing.rs | 2 +- .../src/sections/railgun_section/tests.rs | 10 +- .../src/sections/torpedo_section/bay.rs | 16 +- .../src/sections/turret_section/firing.rs | 11 +- .../src/sections/turret_section/setup.rs | 2 +- crates/nova_wfc/src/check.rs | 6 +- crates/nova_world/src/tests.rs | 13 +- crates/nova_world/src/tests/frozen.rs | 18 +- crates/nova_world_base/Cargo.toml | 5 +- crates/nova_world_base/src/clusters.rs | 66 +- crates/nova_world_base/src/layered.rs | 21 +- crates/nova_world_base/src/lib.rs | 24 +- crates/nova_world_base/src/save/mod.rs | 108 +++- crates/nova_world_base/src/save/session.rs | 19 +- crates/nova_world_base/src/save/tests.rs | 483 ++++++++++++-- crates/nova_world_base/src/sector_ships.rs | 71 +- crates/nova_world_base/src/test_support.rs | 4 +- crates/nova_world_base/src/tests.rs | 26 +- docs/architecture.md | 2 +- docs/sections.md | 2 +- examples/playable/block_bench.rs | 10 +- examples/playable/first_shift_map.rs | 11 +- examples/playable/first_shift_ships.rs | 11 +- examples/playable/mine_and_sell.rs | 53 +- examples/playable/shape_bench.rs | 10 +- examples/playable/trade_loop.rs | 46 +- examples/playable/world_clusters.rs | 12 +- examples/playable/world_encounters.rs | 40 +- examples/playable/world_features.rs | 3 +- examples/playable/world_field_clouds.rs | 3 +- examples/playable/world_field_slices.rs | 3 +- examples/playable/world_sector_inspector.rs | 8 +- examples/playable/world_ships.rs | 10 +- examples/screenshots/lesson_cargo.rs | 30 +- examples/screenshots/lesson_combat_battery.rs | 4 +- examples/screenshots/lesson_combat_reach.rs | 2 +- .../screenshots/lesson_generated_world.rs | 34 +- examples/screenshots/lesson_interface_ship.rs | 6 +- examples/screenshots/loop_goto_standoff.rs | 11 +- examples/screenshots/loop_helm_orders.rs | 11 +- .../screenshots/screenshot_mining_beam.rs | 4 +- .../screenshots/screenshot_ship_repair.rs | 6 +- examples/shared/world_fixture/clustered.rs | 22 +- examples/shared/world_fixture/mod.rs | 7 +- examples/systems/system_world_resume.rs | 50 +- examples/systems/system_world_sectors.rs | 6 + tasks/20260930-100831/TASK.md | 18 +- web/src/create/base-content.md | 44 +- web/src/create/mod-files.md | 62 +- web/src/create/objects.md | 25 +- web/src/docs-manifest.js | 5 +- web/src/wiki/commands.md | 9 +- web/src/wiki/getting-started.md | 5 +- web/src/wiki/interface.md | 7 +- web/src/wiki/sections/mining-beam.md | 2 +- webmods/the-ledger/CHANGELOG.md | 3 + .../ledger_01_drift_run.content.ron | 2 +- .../ledger_02_rock_garden.content.ron | 2 +- .../ledger_03_blue_survey.content.ron | 2 +- .../ledger_04_cold_patrol.content.ron | 2 +- .../ledger_05_freight_lane.content.ron | 2 +- .../ledger_06_siege_line.content.ron | 2 +- 142 files changed, 4613 insertions(+), 1184 deletions(-) create mode 100644 assets/base/items/base.content.ron create mode 100644 crates/nova_assets/src/items.rs create mode 100644 crates/nova_authoring/src/base_content/items.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f2578a5..24a10e812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,14 +36,20 @@ does NOT get an entry. Another page that states a different rule is wrong. - **(breaking)** A scenario object id or a ship section id may not contain '/', and two sections of one ship may not share an id. Both fail at lint and at load. +- **(breaking)** Items are content: mods add `Item` entries that ships carry, + trade and jettison. Stock keys are quoted, `{"HullPlate": 8}`; unknown ids + fail at lint and at load. +- **(breaking)** Two mods that define one item id are both refused unless one + depends on the other, which replaces it whole. Base must define the ten + item ids the game uses, each in a fixed category. ### Interface & HUD - GOTO's predicted path now curves for a coasting or tumbling target, gravity included. It hides while the target changes course or spin; the guide stays. -- Create names the world, New World by default. Load lists saved worlds, why - one cannot load, and deletes one on confirm. A status line shows the last - save; leaving shows a save overlay with Try again. +- Create names the world, New World by default. Load checks saved worlds in + the background, says why one cannot load, and deletes on confirm. A status + line shows the last save; leaving offers a retry. ### Web & Platform diff --git a/assets/base/base.bundle.ron b/assets/base/base.bundle.ron index 67d85cff7..90a360722 100644 --- a/assets/base/base.bundle.ron +++ b/assets/base/base.bundle.ron @@ -20,6 +20,7 @@ // than a thing a scenario names, and the Settings picker reads this // order, so the shipped default has to stand first in it. "ui_themes/base.content.ron", + "items/base.content.ron", "sections/base.content.ron", "styles/base.content.ron", "ships/base.content.ron", diff --git a/assets/base/items/base.content.ron b/assets/base/items/base.content.ron new file mode 100644 index 000000000..6c043f80f --- /dev/null +++ b/assets/base/items/base.content.ron @@ -0,0 +1,92 @@ +[ + Item(( + id: "HullPlate", + name: "Hull plate", + about: "Structural plating for hull sections.", + category: Repair, + mass_g: 10000, + ask_cr: 40, + bid_cr: 30, + )), + Item(( + id: "PdcRound", + name: "PDC round", + about: "Point-defense round. Kinetic and Pierce mounts reload from it.", + category: Ammo, + mass_g: 200, + ask_cr: 4, + bid_cr: 3, + )), + Item(( + id: "RailSlug", + name: "Rail slug", + about: "Railgun slug. Railgun mounts reload from it.", + category: Ammo, + mass_g: 20000, + ask_cr: 40, + bid_cr: 30, + )), + Item(( + id: "Torpedo", + name: "Torpedo", + about: "Torpedo. Every torpedo bay reloads from it.", + category: Ammo, + mass_g: 150000, + ask_cr: 400, + bid_cr: 300, + )), + Item(( + id: "StoneOre", + name: "Stone ore", + about: "Silicate rock, mined from rock asteroids.", + category: Raw, + mass_g: 10000, + ask_cr: 4, + bid_cr: 3, + )), + Item(( + id: "IronOre", + name: "Iron ore", + about: "Metal-rich ore, mined from metal asteroids.", + category: Raw, + mass_g: 10000, + ask_cr: 16, + bid_cr: 12, + )), + Item(( + id: "WaterIce", + name: "Water ice", + about: "Frozen volatiles, mined from ice asteroids.", + category: Raw, + mass_g: 10000, + ask_cr: 12, + bid_cr: 9, + )), + Item(( + id: "CarbonOre", + name: "Carbon ore", + about: "Carbon-rich ore, mined from carbon asteroids.", + category: Raw, + mass_g: 10000, + ask_cr: 8, + bid_cr: 6, + )), + Item(( + id: "Rations", + name: "Rations", + about: "Packed crew food. Traders buy and sell it.", + category: Food, + mass_g: 2000, + ask_cr: 8, + bid_cr: 6, + )), + Item(( + id: "SalvagedParts", + name: "Salvaged parts", + about: "Reusable ship components. Traders buy and sell them.", + category: Parts, + mass_g: 25000, + ask_cr: 120, + bid_cr: 90, + )), +] diff --git a/assets/base/scenarios/drill_autopilot.content.ron b/assets/base/scenarios/drill_autopilot.content.ron index ebf74ab3e..37dd5fcbc 100644 --- a/assets/base/scenarios/drill_autopilot.content.ron +++ b/assets/base/scenarios/drill_autopilot.content.ron @@ -59,7 +59,7 @@ stop_enabled: false, ), inventory: { - PdcRound: 2000, + "PdcRound": 2000, }, lootable: false, credits: 2000, diff --git a/assets/base/scenarios/drill_gunnery.content.ron b/assets/base/scenarios/drill_gunnery.content.ron index a8d070ea9..dfdd07b0c 100644 --- a/assets/base/scenarios/drill_gunnery.content.ron +++ b/assets/base/scenarios/drill_gunnery.content.ron @@ -60,7 +60,7 @@ orbit_enabled: false, ), inventory: { - PdcRound: 2000, + "PdcRound": 2000, }, lootable: false, credits: 2000, diff --git a/assets/base/scenarios/drill_momentum.content.ron b/assets/base/scenarios/drill_momentum.content.ron index 0fc685135..23acf6036 100644 --- a/assets/base/scenarios/drill_momentum.content.ron +++ b/assets/base/scenarios/drill_momentum.content.ron @@ -62,7 +62,7 @@ lock_enabled: false, ), inventory: { - PdcRound: 2000, + "PdcRound": 2000, }, lootable: false, credits: 2000, diff --git a/assets/base/scenarios/drill_stop.content.ron b/assets/base/scenarios/drill_stop.content.ron index 22ea95f64..ec9391f62 100644 --- a/assets/base/scenarios/drill_stop.content.ron +++ b/assets/base/scenarios/drill_stop.content.ron @@ -61,7 +61,7 @@ lock_enabled: false, ), inventory: { - PdcRound: 2000, + "PdcRound": 2000, }, lootable: false, credits: 2000, diff --git a/assets/base/scenarios/menu_duel.content.ron b/assets/base/scenarios/menu_duel.content.ron index aa30a9b0d..df26977c2 100644 --- a/assets/base/scenarios/menu_duel.content.ron +++ b/assets/base/scenarios/menu_duel.content.ron @@ -528,7 +528,7 @@ ), allegiance: Some(Player), inventory: { - PdcRound: 2000, + "PdcRound": 2000, }, lootable: false, credits: 2000, @@ -1198,7 +1198,7 @@ 0.0, ), inventory: { - PdcRound: 2000, + "PdcRound": 2000, }, lootable: false, credits: 2000, diff --git a/assets/base/scenarios/open_world.content.ron b/assets/base/scenarios/open_world.content.ron index 8f01b4e80..dea21fe70 100644 --- a/assets/base/scenarios/open_world.content.ron +++ b/assets/base/scenarios/open_world.content.ron @@ -76,10 +76,10 @@ 0.0, ), inventory: { - HullPlate: 12, - PdcRound: 6000, - RailSlug: 20, - Torpedo: 12, + "HullPlate": 12, + "PdcRound": 6000, + "RailSlug": 20, + "Torpedo": 12, }, lootable: false, credits: 2000, diff --git a/assets/base/scenarios/tutorial.content.ron b/assets/base/scenarios/tutorial.content.ron index 1f9924d96..1f718281f 100644 --- a/assets/base/scenarios/tutorial.content.ron +++ b/assets/base/scenarios/tutorial.content.ron @@ -63,7 +63,7 @@ rcs_enabled: false, ), inventory: { - PdcRound: 2000, + "PdcRound": 2000, }, lootable: false, credits: 2000, diff --git a/assets/mods/example/CHANGELOG.md b/assets/mods/example/CHANGELOG.md index 624197b7a..80bb2e6ac 100644 --- a/assets/mods/example/CHANGELOG.md +++ b/assets/mods/example/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.4.0 + +- **(breaking)** Add `example_survey_core`, an item this mod defines, and + start the arena's player ship with two. Needs a base game whose items are + content; item ids in ship stock are quoted map keys. + ## 1.3.0 - Add `example_freighter_hull`, a `Grammar` under a new id: a hull LINE the diff --git a/assets/mods/example/README.md b/assets/mods/example/README.md index db3e6ce8b..47bd4385f 100644 --- a/assets/mods/example/README.md +++ b/assets/mods/example/README.md @@ -33,6 +33,8 @@ binary assets it ships). longer, blunt-nosed hauler drawing this mod's own plate and no weapons. The editor's Generate block lists it under HULL LINE beside the base warship and collapses whichever is picked. +- **A new item** - `example_survey_core` adds a fresh id to the item catalog; + the arena's player ship starts with two of it in its hold. ## Layout @@ -40,7 +42,7 @@ binary assets it ships). example/ example.bundle.ron # manifest: content + resources + meta example.content.ron # section overlay + new section + arena + menu backdrop - # + hull line + # + hull line + item textures/ nebula.png # self:// skybox (stacked 1x6 cube faces) nebula.png.meta # RowCount cube reinterpret sidecar (rides along, not listed) diff --git a/assets/mods/example/example.bundle.ron b/assets/mods/example/example.bundle.ron index 826dfc0a6..7225f4646 100644 --- a/assets/mods/example/example.bundle.ron +++ b/assets/mods/example/example.bundle.ron @@ -9,8 +9,9 @@ // // What this bundle carries: // - content: one `*.content.ron` file. It OVERRIDES a base section by id, ADDS -// a new section, ADDS a playable arena scenario, and ADDS a menu-backdrop -// scenario - see the header of example.content.ron for the tour. +// a new section, ADDS a playable arena scenario, ADDS a menu-backdrop +// scenario, and ADDS an item - see the header of example.content.ron for +// the tour. // - resources: the binary files this mod ships itself (bundle-dir-relative, // the same base as `content` and `meta.icon`). Content references them with // `self://`, which resolves against THIS folder - `mods/example/...` @@ -32,9 +33,9 @@ ], meta: ( name: "Example Mod", - description: "The copy-me tutorial mod: a section overlay, a new section, a playable arena, mod-shipped art, and a menu backdrop - a little of everything.", + description: "The copy-me tutorial mod: a section overlay, a new section, a playable arena, mod-shipped art, a menu backdrop, and an item - a little of everything.", author: "Nova Protocol", - version: "1.3.0", + version: "1.4.0", // base is an implicit dependency and is never declared (see the header // comment); list only OTHER mods this one depends on. This mod has none. dependencies: [], diff --git a/assets/mods/example/example.content.ron b/assets/mods/example/example.content.ron index 26f5bfd57..9317d2fde 100644 --- a/assets/mods/example/example.content.ron +++ b/assets/mods/example/example.content.ron @@ -4,7 +4,7 @@ // // This bundle loads AFTER base (base first, mods in catalog order), so overlay // is last-wins by id: an item whose id already exists REPLACES it; a fresh id -// ADDS alongside. This file does four things, in order: +// ADDS alongside. This file does five things, in order: // // 1. OVERRIDE a base section - `reinforced_hull_section` (same id -> replace) // 2. ADD a new section - `example_plated_hull_section` (new id -> add) @@ -13,6 +13,8 @@ // win check, and Victory/Defeat Outcomes) // 4. ADD a menu backdrop - `example_menu` (a `role: Backdrop` scene: // mods can ship main-menu ambience) +// 5. ADD an item - `example_survey_core` (cargo the arena's +// player ship starts with) // // Everything here is pure data - no engine changes. For the field-by-field // syntax behind it see the wiki: "Author a section", "Author a scenario", @@ -370,7 +372,10 @@ ], )), initial_velocity: (0.0, 0.0, 0.0), - inventory: {}, + // Two of the item this mod adds (5 below): it + // weighs, trades and jettisons like a base item. + // Item ids are quoted map keys. + inventory: {"example_survey_core": 2}, lootable: false, credits: 2_000, )), @@ -829,4 +834,21 @@ ), ], )), + // -- 5. ITEM --------------------------------------------------------------- + // A NEW item id, so it ADDS to the catalog. Any ship stock, canister or + // trade may carry it. Every field is required: `mass_g` above 0 grams, and + // `bid_cr` (what a Sell earns) at most `ask_cr` (what a Buy pays). The ten + // ids the game itself uses - "HullPlate", the ammunition, the ores, + // "Rations" and "SalvagedParts" - belong to base. A mod may replace one + // whole, with its own mass and prices but the same category, because base + // is a dependency of every mod. + Item(( + id: "example_survey_core", + name: "Survey core", + about: "A sealed sensor core from the example mod. Traders buy and sell it.", + category: Parts, + mass_g: 5000, + ask_cr: 60, + bid_cr: 45, + )), ] diff --git a/crates/nova_assets/Cargo.toml b/crates/nova_assets/Cargo.toml index 06849481b..ddb1c5dbc 100644 --- a/crates/nova_assets/Cargo.toml +++ b/crates/nova_assets/Cargo.toml @@ -64,6 +64,9 @@ wasm-bindgen-futures = "0.4" [dev-dependencies] nova_events = { path = "../nova_events" } +# `test_items`: a synthetic base pack must define every role item, or the +# merge refuses it. +nova_gameplay = { path = "../nova_gameplay", features = ["test-support"] } # The provocation walk spawns real ships through the OnStart action, so it needs # `test_support` to run the rig out to a LIVE world before firing the next # event: the dispatcher holds every handler while objects are still landing. diff --git a/crates/nova_assets/src/items.rs b/crates/nova_assets/src/items.rs new file mode 100644 index 000000000..7dc17db82 --- /dev/null +++ b/crates/nova_assets/src/items.rs @@ -0,0 +1,318 @@ +//! The item catalog rule: which packs may define which item, and what a valid +//! item is. +//! +//! An item id may be defined by several packs only along a dependency chain: +//! a pack that depends on another, directly or through others, may replace +//! that pack's whole item, and the merge order puts it last. Two packs that +//! define one id with no such chain between them, or with a chain both ways +//! (a dependency cycle), have no order the rule can trust, so BOTH are refused, +//! whatever order they load in. The merge then refuses every pack that +//! depends on a refused one. The base game is a dependency of every other pack. +//! +//! The runtime merge (`register_bundles`) and the `content lint` walk both +//! call [`item_pack_faults`], so a pack lint passes is a pack the game loads. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use nova_gameplay::prelude::{ItemDesign, CARGO_CANISTER_MAX_MASS_G, ITEM_ROLES}; +use nova_mod_format::deps::{transitive_deps, DepGraph}; +use nova_modding::prelude::BASE_MOD_ID; +use nova_scenario::prelude::{ore_for_asteroid_kind, AsteroidKindId, ASTEROID_KINDS}; + +/// Glob-import surface: `use nova_assets::items::prelude::*` re-exports the +/// public API of this module. +pub mod prelude { + pub use super::{item_pack_faults, ItemPack}; +} + +/// One pack's item definitions, as the rule reads them. +#[derive(Clone, Debug)] +pub struct ItemPack<'a> { + /// The mod id. + pub id: &'a str, + /// The mod ids it declares as dependencies. The base game is implicit. + pub dependencies: &'a [String], + /// Every item it defines, in authored order, repeats included. + pub items: Vec<&'a ItemDesign>, +} + +/// Every fault the item rule finds, by the pack it refuses, in pack id order. +/// Empty when every pack may load. Independent of the order of `packs`. +/// +/// A pack is refused for an invalid item it defines, an item id it defines +/// twice, an item id it shares with a pack it has no one-way dependency +/// chain with, and a dependency chain too deep to walk. The base game is +/// also refused when it lacks one of [`ITEM_ROLES`]. +pub fn item_pack_faults(packs: &[ItemPack<'_>]) -> BTreeMap> { + let mut faults: BTreeMap> = BTreeMap::new(); + let mut fault = |pack: &str, message: String| { + faults.entry(pack.to_string()).or_default().push(message); + }; + + let graph: DepGraph = packs + .iter() + .map(|pack| (pack.id.to_string(), pack.dependencies.to_vec())) + .collect(); + let mut reaches: HashMap<&str, HashSet> = HashMap::new(); + for pack in packs { + match transitive_deps(&graph, pack.id) { + Ok(deps) => { + let mut deps: HashSet = deps.into_iter().collect(); + if pack.id != BASE_MOD_ID { + deps.insert(BASE_MOD_ID.to_string()); + } + reaches.insert(pack.id, deps); + } + Err(error) => fault(pack.id, error.to_string()), + } + } + + let mut definers: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for pack in packs { + let mut seen = HashSet::new(); + for item in &pack.items { + for message in item_faults(item) { + fault(pack.id, message); + } + let id = item.id.as_str(); + if !seen.insert(id) { + fault(pack.id, format!("item '{id}' is defined more than once")); + } + definers.entry(id).or_default().insert(pack.id); + } + } + + // Only a walked pack can be judged; a pack whose walk failed is refused + // above already. + let depends = |a: &str, b: &str| reaches.get(a).is_some_and(|deps| deps.contains(b)); + for (id, owners) in &definers { + let owners: Vec<&str> = owners.iter().copied().collect(); + for (at, a) in owners.iter().enumerate() { + for b in &owners[at + 1..] { + let reason = match (depends(a, b), depends(b, a)) { + (true, false) | (false, true) => continue, + (true, true) => "they depend on each other", + (false, false) => "neither depends on the other", + }; + let message = format!("item '{id}' is defined by '{a}' and '{b}', and {reason}"); + fault(a, message.clone()); + fault(b, message); + } + } + } + + if let Some(base) = packs.iter().find(|pack| pack.id == BASE_MOD_ID) { + for (role, _) in ITEM_ROLES { + if !base.items.iter().any(|item| item.id.as_str() == role) { + fault(BASE_MOD_ID, format!("item '{role}' is not defined")); + } + } + } + faults +} + +/// What is wrong with one item definition on its own. +fn item_faults(item: &ItemDesign) -> Vec { + let id = item.id.as_str(); + let mut faults = Vec::new(); + if id.trim().is_empty() { + faults.push("an item has an empty id".to_string()); + } + if item.name.trim().is_empty() { + faults.push(format!("item '{id}' has an empty name")); + } + if item.about.trim().is_empty() { + faults.push(format!("item '{id}' has an empty about")); + } + if item.mass_g == 0 { + faults.push(format!("item '{id}' has a mass_g of 0")); + } + if item.bid_cr > item.ask_cr { + faults.push(format!( + "item '{id}' has a bid_cr of {} above its ask_cr of {}", + item.bid_cr, item.ask_cr + )); + } + if let Some((_, category)) = ITEM_ROLES.iter().find(|(role, _)| *role == id) { + if item.category != *category { + faults.push(format!( + "item '{id}' is filed under {:?}; the game uses it as {category:?}", + item.category + )); + } + } + // Mining packs each ore it yields into one canister. + let mined = ASTEROID_KINDS + .iter() + .filter_map(|kind| ore_for_asteroid_kind(&AsteroidKindId::from(*kind))) + .any(|ore| ore.as_str() == id); + if mined && item.mass_g > CARGO_CANISTER_MAX_MASS_G { + faults.push(format!( + "item '{id}' has a mass_g of {}; mined ore must fit one {CARGO_CANISTER_MAX_MASS_G} g \ + canister", + item.mass_g + )); + } + faults +} + +#[cfg(test)] +mod tests { + use nova_gameplay::prelude::{ItemCategoryType, ItemDesignId}; + + use super::*; + + fn item(id: &str, mass_g: u32) -> ItemDesign { + let category = ITEM_ROLES + .iter() + .find(|(role, _)| *role == id) + .map_or(ItemCategoryType::Parts, |(_, category)| *category); + ItemDesign { + id: ItemDesignId::from(id), + name: id.to_string(), + about: format!("{id} for tests."), + category, + mass_g, + ask_cr: 10, + bid_cr: 5, + } + } + + fn base_items() -> Vec { + ITEM_ROLES + .iter() + .map(|(role, _)| item(role, 1_000)) + .collect() + } + + fn pack<'a>(id: &'a str, deps: &'a [String], items: &'a [ItemDesign]) -> ItemPack<'a> { + ItemPack { + id, + dependencies: deps, + items: items.iter().collect(), + } + } + + /// Two unrelated packs that define one id are both refused, and the + /// verdict is the same in either load order. + #[test] + fn unrelated_packs_defining_one_item_are_both_refused_in_any_order() { + let base = base_items(); + let core = [item("deep-salvage/reactor_core", 80_000)]; + let rival = [item("deep-salvage/reactor_core", 1_000)]; + let none: [String; 0] = []; + let forward = item_pack_faults(&[ + pack(BASE_MOD_ID, &none, &base), + pack("deep-salvage", &none, &core), + pack("ore-rush", &none, &rival), + ]); + let backward = item_pack_faults(&[ + pack("ore-rush", &none, &rival), + pack("deep-salvage", &none, &core), + pack(BASE_MOD_ID, &none, &base), + ]); + assert_eq!(forward, backward); + let message = "item 'deep-salvage/reactor_core' is defined by 'deep-salvage' and \ + 'ore-rush', and neither depends on the other"; + assert_eq!( + forward, + BTreeMap::from([ + ("deep-salvage".to_string(), vec![message.to_string()]), + ("ore-rush".to_string(), vec![message.to_string()]), + ]) + ); + } + + /// A pack may replace an item of a pack it depends on, the base game + /// included, with its own mass and prices but a role item's category; a + /// pack the item's owner depends on back may not. + #[test] + fn a_dependent_pack_may_replace_an_item_and_a_cycle_may_not() { + let base = base_items(); + let mut rations = item(nova_gameplay::prelude::ITEM_RATIONS, 3_000); + rations.ask_cr = 20; + rations.bid_cr = 15; + let plate = [ + item(nova_gameplay::prelude::ITEM_HULL_PLATE, 12_000), + item(nova_gameplay::prelude::ITEM_SALVAGED_PARTS, 30_000), + rations.clone(), + ]; + let core = [item("deep-salvage/reactor_core", 80_000)]; + let none: [String; 0] = []; + let on_salvage = ["deep-salvage".to_string()]; + assert!(item_pack_faults(&[ + pack(BASE_MOD_ID, &none, &base), + pack("heavy-plates", &none, &plate), + pack("deep-salvage", &none, &core), + pack("salvage-plus", &on_salvage, &core), + ]) + .is_empty()); + + rations.category = ItemCategoryType::Parts; + let refiled = [rations]; + assert_eq!( + item_pack_faults(&[ + pack(BASE_MOD_ID, &none, &base), + pack("cheap-rations", &none, &refiled), + ]), + BTreeMap::from([( + "cheap-rations".to_string(), + vec!["item 'Rations' is filed under Parts; the game uses it as Food".to_string()], + )]) + ); + + let on_plus = ["salvage-plus".to_string()]; + let faults = item_pack_faults(&[ + pack(BASE_MOD_ID, &none, &base), + pack("deep-salvage", &on_plus, &core), + pack("salvage-plus", &on_salvage, &core), + ]); + assert_eq!( + faults.keys().collect::>(), + ["deep-salvage", "salvage-plus"] + ); + assert!(faults["deep-salvage"][0].ends_with("they depend on each other")); + } + + /// The base game must define every role item under its category, an id + /// may appear once per pack, a mass of zero is refused, and an ore mining + /// yields must fit one canister. + #[test] + fn the_base_game_keeps_every_role_and_no_pack_repeats_or_weightless_items() { + let mut base = base_items(); + base.retain(|item| { + ![ + nova_gameplay::prelude::ITEM_TORPEDO, + nova_gameplay::prelude::ITEM_SALVAGED_PARTS, + ] + .contains(&item.id.as_str()) + }); + base[0].category = ItemCategoryType::Food; + for item in &mut base { + match item.id.as_str() { + nova_gameplay::prelude::ITEM_STONE_ORE => { + item.mass_g = CARGO_CANISTER_MAX_MASS_G + 1; + } + nova_gameplay::prelude::ITEM_RATIONS => item.category = ItemCategoryType::Parts, + _ => {} + } + } + base.push(item("Scrap", 0)); + base.push(item("Scrap", 2_000)); + let none: [String; 0] = []; + let faults = item_pack_faults(&[pack(BASE_MOD_ID, &none, &base)]); + assert_eq!( + faults[BASE_MOD_ID], + [ + "item 'HullPlate' is filed under Food; the game uses it as Repair", + "item 'StoneOre' has a mass_g of 200001; mined ore must fit one 200000 g \ + canister", + "item 'Rations' is filed under Parts; the game uses it as Food", + "item 'Scrap' has a mass_g of 0", + "item 'Scrap' is defined more than once", + "item 'Torpedo' is not defined", + "item 'SalvagedParts' is not defined", + ] + ); + } +} diff --git a/crates/nova_assets/src/lib.rs b/crates/nova_assets/src/lib.rs index a24acbf3f..057303858 100644 --- a/crates/nova_assets/src/lib.rs +++ b/crates/nova_assets/src/lib.rs @@ -26,6 +26,7 @@ mod plugin; mod reload; mod safe_mode; +pub mod items; #[cfg(not(target_arch = "wasm32"))] pub mod loose; pub mod mod_cache; diff --git a/crates/nova_assets/src/merge.rs b/crates/nova_assets/src/merge.rs index 0c6a35be7..f7f7ab7ae 100644 --- a/crates/nova_assets/src/merge.rs +++ b/crates/nova_assets/src/merge.rs @@ -1,7 +1,7 @@ //! The content MERGE: flatten every enabled bundle's `Content` in dependency //! order and overlay it by id into the game's registries (`GameSections`, -//! `GameShipDesigns`, `GameScenarios`, `GameCampaigns`, `GameStyles`), linting -//! the result as it goes. +//! `GameShipDesigns`, `GameScenarios`, `GameCampaigns`, `GameStyles`, +//! `GameItems`), linting the result as it goes. /// Glob-import surface: `use nova_assets::merge::prelude::*` re-exports the /// public API of this module. @@ -17,7 +17,7 @@ mod canonical; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use bevy::prelude::*; -use nova_gameplay::prelude::{Fnv64, GameStates}; +use nova_gameplay::prelude::{Fnv64, GameItems, GameStates, ItemDesign}; use nova_modding::prelude::{BundleAsset, Content, ContentAsset, InstalledCatalog, BASE_MOD_ID}; use nova_scenario::prelude::{ GameCampaigns, GameScenarios, GameShipDesigns, NewGameStart, ScenarioRole, ShipDesignPrototype, @@ -65,9 +65,12 @@ use crate::{ /// Every section a loaded enabled bundle carries passes /// [`lint_section_config`](nova_scenario::prelude::lint_section_config), and /// every ship design it carries passes -/// [`section_id_errors`](nova_scenario::prelude::section_id_errors), before -/// anything is published, used or not, and the finding is charged to the -/// bundle that authored it. An error in the base bundle refuses the load: it +/// [`section_id_errors`](nova_scenario::prelude::section_id_errors), and every +/// bundle passes the item rule +/// ([`item_pack_faults`](crate::items::item_pack_faults)), before anything is +/// published, used or not, and the finding is charged to the bundle that +/// authored it. Two unrelated bundles that define one item id are both +/// charged. An error in the base bundle refuses the load: it /// inserts [`FatalAssetFailure`] and publishes nothing. An error in any other /// bundle quarantines that whole mod and every enabled mod that depends on it, /// and this pass merges without them - so no registry ever holds an invalid @@ -163,6 +166,35 @@ pub fn register_bundles( 1 => errors[0].message.clone(), n => format!("{} (+{} more)", errors[0].message, n - 1), }; + // The item rule judges every loaded bundle at once: whether two packs may + // define one item depends on both, never on which loaded first. + let item_packs: Vec<(&str, &BundleAsset, Vec<&ItemDesign>)> = ordered + .iter() + .filter_map(|(mod_id, handle)| { + let bundle = bundles.get(*handle)?; + let items = bundle + .content + .iter() + .filter_map(|handle| contents.get(handle)) + .flat_map(|content| content.0.iter()) + .filter_map(|item| match item { + Content::Item(design) => Some(design), + _ => None, + }) + .collect(); + Some((*mod_id, bundle, items)) + }) + .collect(); + let mut item_faults = crate::items::item_pack_faults( + &item_packs + .iter() + .map(|(id, bundle, items)| crate::items::ItemPack { + id, + dependencies: &bundle.meta.dependencies, + items: items.clone(), + }) + .collect::>(), + ); // Every bundle is checked before any verdict, so a refused base changes no // mod: the run ends, and nothing about the enabled set is persisted. let mut base_errors = Vec::new(); @@ -171,7 +203,18 @@ pub fn register_bundles( let Some(bundle) = bundles.get(*handle) else { continue; }; - let errors = section_errors(mod_id, bundle, &contents); + let mut errors = section_errors(mod_id, bundle, &contents); + errors.extend( + item_faults + .remove(*mod_id) + .into_iter() + .flatten() + .map(|message| nova_scenario::prelude::LintIssue { + severity: nova_scenario::prelude::LintSeverity::Error, + scenario: mod_id.to_string(), + message, + }), + ); if errors.is_empty() { continue; } @@ -498,6 +541,7 @@ pub fn register_bundles( .filter(|(_, role)| !role.is_backdrop()) .map(|(id, _)| id.clone()) .collect(); + let merged_items = GameItems::new(outcome.items); let mut content_issues = nova_scenario::prelude::ContentIssues::default(); // Every MERGED ship, checked where it is authored: a scenario referencing // one only checks that the id resolves, so this is the pass that sees the @@ -528,6 +572,7 @@ pub fn register_bundles( &merged_sections, &merged_ships, &merged_scenarios, + &merged_items, ); for issue in &found { warn!( @@ -617,6 +662,7 @@ pub fn register_bundles( commands.insert_resource(GameShipDesigns(outcome.ships)); commands.insert_resource(TrainingCatalog::new(outcome.lessons)); commands.insert_resource(GameUiThemes(outcome.ui_themes)); + commands.insert_resource(merged_items); } /// Every error [`lint_section_config`](nova_scenario::prelude::lint_section_config) @@ -670,7 +716,7 @@ fn section_errors( /// The version of the canonical form [`ContentCatalogDigest`] hashes. Change /// it with any change to what the digest covers or how it is written, so an /// old pin cannot match a new form. -const CONTENT_CATALOG_DIGEST_VERSION: u32 = 1; +const CONTENT_CATALOG_DIGEST_VERSION: u32 = 2; /// One merged bundle: its mod id, its asset and its rewritten content items, /// in merge order. @@ -836,6 +882,10 @@ pub struct MergeOutcome { /// new id. The order is what the Settings picker lists, so the base mod's /// default stays first. pub ui_themes: Vec, + /// Items in registration order, overlaid last-wins by id. The gate in + /// [`register_bundles`] has already refused every pack that may not + /// replace an item it shares, so the winner is the dependent pack. + pub items: Vec, /// Human-readable messages, one per intra-bundle duplicate id that was /// skipped. Empty on clean data. pub conflicts: Vec, @@ -937,6 +987,10 @@ fn merge_content_item(item: &Content, into: &mut MergeOutcome) { Some(existing) => *existing = cfg.as_ref().clone(), None => into.ui_themes.push(cfg.as_ref().clone()), }, + Content::Item(design) => match into.items.iter_mut().find(|i| i.id == design.id) { + Some(existing) => *existing = design.clone(), + None => into.items.push(design.clone()), + }, } } diff --git a/crates/nova_assets/src/plugin.rs b/crates/nova_assets/src/plugin.rs index 6f6ec471b..d969946fb 100644 --- a/crates/nova_assets/src/plugin.rs +++ b/crates/nova_assets/src/plugin.rs @@ -355,7 +355,7 @@ fn finish_processing(mut state: ResMut>) { mod tests { use bevy::{asset::AssetPlugin, state::app::StatesPlugin}; use nova_events::units::prelude::Meters; - use nova_gameplay::prelude::AssetRef; + use nova_gameplay::{prelude::AssetRef, test_support::test_items}; use nova_modding::prelude::{ BundleAsset, CatalogEntry, Content, ContentAsset, InstalledCatalog, ModEntry, ModMeta, NovaModdingPlugin, @@ -462,6 +462,11 @@ mod tests { }; (entry, content) }; + // The merge refuses a base pack without every role item. + let base = base + .into_iter() + .chain(test_items().iter().cloned().map(Content::Item)) + .collect(); let (base_entry, base_content) = entry("base", true, base); let (extra_entry, extra_content) = entry("extra", false, extra); let catalog = app diff --git a/crates/nova_assets/tests/example_scenario.rs b/crates/nova_assets/tests/example_scenario.rs index 166b37ee4..22a6ebb85 100644 --- a/crates/nova_assets/tests/example_scenario.rs +++ b/crates/nova_assets/tests/example_scenario.rs @@ -19,11 +19,17 @@ use bevy::{ state::app::StatesPlugin, }; use nova_assets::prelude::*; -use nova_gameplay::prelude::GameStates; +use nova_gameplay::{ + prelude::{GameItems, GameStates, ItemCategoryType, ItemDesign, ItemDesignId, ShipInventory}, + test_support::test_items, +}; use nova_modding::prelude::{ BundleAsset, CatalogEntry, Content, ContentAsset, InstalledCatalog, ModEntry, NovaModdingPlugin, }; -use nova_scenario::prelude::{ContentIssues, GameScenarios, NewGameStart, ScenarioConfig}; +use nova_scenario::prelude::{ + ContentIssues, EventActionConfig, GameScenarios, NewGameStart, ScenarioConfig, + ScenarioObjectKind, +}; use nova_ship::prelude::GameSections; /// A headless app with the asset server pointed at the workspace `assets/` and the @@ -316,10 +322,10 @@ fn mod_catalog_lists_installed_mods_metadata() { ); assert_eq!( mods[1].meta.description, - "The copy-me tutorial mod: a section overlay, a new section, a playable arena, mod-shipped art, and a menu backdrop - a little of everything.", + "The copy-me tutorial mod: a section overlay, a new section, a playable arena, mod-shipped art, a menu backdrop, and an item - a little of everything.", "example's description comes from its bundle meta (the catalog has none)" ); - assert_eq!(mods[1].meta.version, "1.3.0", "bundle meta version decodes"); + assert_eq!(mods[1].meta.version, "1.4.0", "bundle meta version decodes"); assert_eq!(mods[1].meta.author, "Nova Protocol"); } @@ -817,11 +823,18 @@ fn base_bundle_declares_the_new_game_start() { #[test] fn new_game_declaration_is_honored_only_from_base() { let mut app = headless_app(); + // The merge refuses a base pack without every role item. + let base_items = app + .world_mut() + .resource_mut::>() + .add(ContentAsset( + test_items().iter().cloned().map(Content::Item).collect(), + )); let (base_bundle, mod_bundle) = { let mut bundles = app.world_mut().resource_mut::>(); ( bundles.add(BundleAsset { - content: vec![], + content: vec![base_items], meta: ModMeta::default(), new_game_scenario: Some("base_start".to_string()), resources: vec![], @@ -934,7 +947,12 @@ fn merge_sweep_flags_bad_content_and_passes_the_shipped_tree() { let content = app .world_mut() .resource_mut::>() - .add(ContentAsset(vec![Content::Scenario(broken)])); + // The merge refuses a base pack without every role item. + .add(ContentAsset( + std::iter::once(Content::Scenario(broken)) + .chain(test_items().iter().cloned().map(Content::Item)) + .collect(), + )); let bundle = app .world_mut() .resource_mut::>() @@ -972,3 +990,208 @@ fn merge_sweep_flags_bad_content_and_passes_the_shipped_tree() { assert_eq!(errors.len(), 1, "{:?}", issues.0); assert!(errors[0].message.contains("no_such_chapter")); } + +/// The example mod's item reaches the merged catalog only with the mod +/// enabled, with its authored fields, and the arena player's authored stock +/// resolves against that catalog. The ECS jettison, pickup and trade of a +/// catalog-only item are proven in `nova_interface` and `nova_ship`. +#[test] +fn the_example_mod_item_merges_and_the_arena_stock_resolves_against_it() { + let core = ItemDesignId::from("example_survey_core"); + + let base_items = merged_app(&["base"]) + .world() + .resource::() + .clone(); + assert!( + base_items.get(&core).is_none(), + "the example item must not appear without the mod enabled" + ); + + let app = merged_app(&["base", "example"]); + let items = app.world().resource::().clone(); + let design = items + .get(&core) + .expect("the enabled example mod's item must be in the merged catalog"); + assert_eq!(design.name, "Survey core"); + assert_eq!( + design.about, + "A sealed sensor core from the example mod. Traders buy and sell it." + ); + assert_eq!(design.category, ItemCategoryType::Parts); + assert_eq!(design.mass_g, 5_000); + assert_eq!(design.ask_cr, 60); + assert_eq!(design.bid_cr, 45); + + // The arena's authored `player_spaceship` spawn carries the stock this test moves. + let scenarios = app.world().resource::().clone(); + let arena = scenarios + .get("example_arena") + .expect("the example mod's scenario is registered"); + let stock = arena + .events + .iter() + .flat_map(|event| &event.actions) + .find_map(|action| match action { + EventActionConfig::SpawnScenarioObject(object) + if object.base.id == "player_spaceship" => + { + match &object.kind { + ScenarioObjectKind::Spaceship(ship) => Some(ship.inventory.clone()), + _ => None, + } + } + _ => None, + }) + .expect("the arena's player_spaceship spawn carries an inventory"); + + let player = ShipInventory::new( + &items, + 200_000, + stock.stacks().map(|(item, count)| (item.clone(), count)), + ); + assert_eq!(player.count(&core), 2); + assert_eq!(player.used_g(&items), 10_000); +} + +/// A fixture item definition, filed under `Parts` like a mod's own item would be. +fn fixture_item(id: &str) -> ItemDesign { + ItemDesign { + id: ItemDesignId::from(id), + name: id.to_string(), + about: format!("{id} fixture item."), + category: ItemCategoryType::Parts, + mass_g: 1_000, + ask_cr: 10, + bid_cr: 5, + } +} + +/// One fixture bundle carrying a single item, with `dependencies` as its meta +/// declares. +fn fixture_item_bundle( + app: &mut App, + dependencies: &[&str], + item: ItemDesign, +) -> Handle { + let content = app + .world_mut() + .resource_mut::>() + .add(ContentAsset(vec![Content::Item(item)])); + app.world_mut() + .resource_mut::>() + .add(BundleAsset { + content: vec![content], + meta: ModMeta { + dependencies: dependencies.iter().map(|dep| dep.to_string()).collect(), + ..ModMeta::default() + }, + new_game_scenario: None, + resources: vec![], + resource_base: "mods/fixture".to_string(), + }) +} + +/// The real catalog plus three fixture mods: `item-a` and `item-b` each define +/// `shared_core` with no dependency between them, and `item-a-plus` depends on +/// `item-a` and defines its own `plus_core`. `item_a_first` picks which of the +/// two colliding entries the catalog lists first. +fn item_collision_fixture_app(item_a_first: bool) -> App { + let mut app = headless_app(); + let asset_server = app.world().resource::().clone(); + let catalog: Handle = asset_server.load("mods.catalog.ron"); + wait_recursive_loaded( + &mut app, + &asset_server, + catalog.id().untyped(), + "the mods catalog", + ); + + let item_a = fixture_item_bundle(&mut app, &[], fixture_item("shared_core")); + let item_b = fixture_item_bundle(&mut app, &[], fixture_item("shared_core")); + let item_a_plus = fixture_item_bundle(&mut app, &["item-a"], fixture_item("plus_core")); + app.world_mut().insert_resource(OptionalBundles(vec![ + OptionalBundle { + id: "item-a".to_string(), + bundle: item_a, + }, + OptionalBundle { + id: "item-b".to_string(), + bundle: item_b, + }, + OptionalBundle { + id: "item-a-plus".to_string(), + bundle: item_a_plus, + }, + ])); + + let order: [&str; 2] = if item_a_first { + ["item-a", "item-b"] + } else { + ["item-b", "item-a"] + }; + let synthetic = { + let catalogs = app.world().resource::>(); + let mut entries = catalogs + .get(&catalog) + .expect("catalog loaded") + .entries + .clone(); + for id in order.into_iter().chain(["item-a-plus"]) { + entries.push(CatalogEntry { + decl: ModEntry { + id: id.to_string(), + bundle: format!("mods/{id}/{id}.bundle.ron"), + base: false, + }, + bundle: None, + }); + } + InstalledCatalog { entries } + }; + let handle = app + .world_mut() + .resource_mut::>() + .add(synthetic); + app.world_mut() + .insert_resource(game_assets_with_catalog(handle)); + app.world_mut().insert_resource(EnabledMods( + ["base", "item-a", "item-b", "item-a-plus"] + .into_iter() + .map(str::to_string) + .collect(), + )); + app.world_mut() + .run_system_once(nova_assets::register_bundles_for_test) + .expect("register bundles"); + app +} + +/// Two enabled mods that define one item id with no dependency between them +/// are both refused, in either catalog order, and so is a mod that depends on +/// one of them; base still loads. +#[test] +fn two_unrelated_mods_defining_one_item_are_both_refused_with_their_dependents() { + for item_a_first in [true, false] { + let app = item_collision_fixture_app(item_a_first); + + let mut disabled: Vec<&str> = app + .world() + .resource::() + .disabled + .iter() + .map(|mod_| mod_.id.as_str()) + .collect(); + disabled.sort_unstable(); + assert_eq!( + disabled, + ["item-a", "item-a-plus", "item-b"], + "item_a_first={item_a_first}" + ); + + let items = app.world().resource::(); + assert!(items.get(&ItemDesignId::from("HullPlate")).is_some()); + assert!(items.get(&ItemDesignId::from("shared_core")).is_none()); + assert!(items.get(&ItemDesignId::from("plus_core")).is_none()); + } +} diff --git a/crates/nova_assets/tests/mod_binary_resources.rs b/crates/nova_assets/tests/mod_binary_resources.rs index 1d5ad7d8f..c829dacca 100644 --- a/crates/nova_assets/tests/mod_binary_resources.rs +++ b/crates/nova_assets/tests/mod_binary_resources.rs @@ -20,7 +20,7 @@ use bevy::{ state::app::StatesPlugin, }; use nova_assets::prelude::*; -use nova_gameplay::prelude::GameStates; +use nova_gameplay::{prelude::GameStates, test_support::test_items}; use nova_modding::prelude::{ BundleAsset, CatalogEntry, Content, ContentAsset, InstalledCatalog, ModEntry, ModMeta, NovaModdingPlugin, @@ -202,7 +202,12 @@ fn an_undeclared_self_ref_is_an_error_content_issue() { let content = app .world_mut() .resource_mut::>() - .add(ContentAsset(vec![Content::Scenario(scenario)])); + // The merge refuses a base pack without every role item. + .add(ContentAsset( + std::iter::once(Content::Scenario(scenario)) + .chain(test_items().iter().cloned().map(Content::Item)) + .collect(), + )); let bundle = app .world_mut() .resource_mut::>() @@ -510,11 +515,18 @@ fn a_dep_ref_to_base_resolves_against_base_folder_without_declaring_base() { // `dep://base/X` WITHOUT listing base in meta.dependencies, and it resolves // against base's own folder. (Synthetic base bundle.) let mut app = headless_app(); + // The merge refuses a base pack without every role item. + let base_items = app + .world_mut() + .resource_mut::>() + .add(ContentAsset( + test_items().iter().cloned().map(Content::Item).collect(), + )); let base_bundle = app .world_mut() .resource_mut::>() .add(BundleAsset { - content: vec![], + content: vec![base_items], meta: ModMeta::default(), new_game_scenario: None, resources: vec!["textures/cubemap.png".to_string()], diff --git a/crates/nova_authoring/src/base_content/items.rs b/crates/nova_authoring/src/base_content/items.rs new file mode 100644 index 000000000..8f3aedb3b --- /dev/null +++ b/crates/nova_authoring/src/base_content/items.rs @@ -0,0 +1,131 @@ +//! Built-in ITEM content: every item the base game's holds, trades, reloads, +//! repairs and mining use. +//! +//! An item is content like a section is content, so this file is a builder - +//! `content gen` serializes it into `assets/base/items/base.content.ron`. A +//! mod adds items under its own ids, and a mod that depends on another pack +//! may replace that pack's whole item. +//! +//! The ids of [`ITEM_ROLES`](nova_gameplay::prelude::ITEM_ROLES) are the ones +//! the game itself uses - weapons reload from them, repair spends one, mining +//! yields them, generated sector ship holds draw them - so the base game must +//! define each under its role's category. A dependent mod may replace one of +//! them whole, with new mass and prices, under the same category. +//! Prices are provisional values, not a balance decision; each bid is below +//! its ask, so buying and selling back loses credits. + +use nova_gameplay::prelude::{ + ItemCategoryType, ItemDesign, ItemDesignId, ITEM_CARBON_ORE, ITEM_HULL_PLATE, ITEM_IRON_ORE, + ITEM_PDC_ROUND, ITEM_RAIL_SLUG, ITEM_RATIONS, ITEM_SALVAGED_PARTS, ITEM_STONE_ORE, + ITEM_TORPEDO, ITEM_WATER_ICE, +}; + +/// The base game's items, in the order the file carries them. +pub(crate) fn item_catalog() -> Vec { + use ItemCategoryType::{Ammo, Food, Parts, Raw, Repair}; + [ + ( + ITEM_HULL_PLATE, + "Hull plate", + "Structural plating for hull sections.", + Repair, + 10_000, + 40, + 30, + ), + ( + ITEM_PDC_ROUND, + "PDC round", + "Point-defense round. Kinetic and Pierce mounts reload from it.", + Ammo, + 200, + 4, + 3, + ), + ( + ITEM_RAIL_SLUG, + "Rail slug", + "Railgun slug. Railgun mounts reload from it.", + Ammo, + 20_000, + 40, + 30, + ), + ( + ITEM_TORPEDO, + "Torpedo", + "Torpedo. Every torpedo bay reloads from it.", + Ammo, + 150_000, + 400, + 300, + ), + ( + ITEM_STONE_ORE, + "Stone ore", + "Silicate rock, mined from rock asteroids.", + Raw, + 10_000, + 4, + 3, + ), + ( + ITEM_IRON_ORE, + "Iron ore", + "Metal-rich ore, mined from metal asteroids.", + Raw, + 10_000, + 16, + 12, + ), + ( + ITEM_WATER_ICE, + "Water ice", + "Frozen volatiles, mined from ice asteroids.", + Raw, + 10_000, + 12, + 9, + ), + ( + ITEM_CARBON_ORE, + "Carbon ore", + "Carbon-rich ore, mined from carbon asteroids.", + Raw, + 10_000, + 8, + 6, + ), + ( + ITEM_RATIONS, + "Rations", + "Packed crew food. Traders buy and sell it.", + Food, + 2_000, + 8, + 6, + ), + ( + ITEM_SALVAGED_PARTS, + "Salvaged parts", + "Reusable ship components. Traders buy and sell them.", + Parts, + 25_000, + 120, + 90, + ), + ] + .into_iter() + .map( + |(id, name, about, category, mass_g, ask_cr, bid_cr)| ItemDesign { + id: ItemDesignId::from(id), + name: name.to_string(), + about: about.to_string(), + category, + mass_g, + ask_cr, + bid_cr, + }, + ) + .collect() +} diff --git a/crates/nova_authoring/src/base_content/mod.rs b/crates/nova_authoring/src/base_content/mod.rs index e312baed5..07242b5d4 100644 --- a/crates/nova_authoring/src/base_content/mod.rs +++ b/crates/nova_authoring/src/base_content/mod.rs @@ -1,16 +1,18 @@ //! The base game's authored content inventory. //! //! The base game is a built-in mod. This domain owns its scenarios, the -//! campaign that groups chapter scenarios, section prototypes, +//! campaign that groups chapter scenarios, items, section prototypes, //! semantic craft assemblies, and path-based asset refs. Generic authoring //! helpers, lint, and serialization tooling stay outside it. +use nova_gameplay::prelude::ItemDesign; use nova_scenario::prelude::{CampaignConfig, ScenarioConfig, ShipDesignPrototype}; use nova_ship::prelude::{SectionConfig, ShipStyleConfig}; use nova_training::prelude::Lesson; pub(crate) mod assets; pub(crate) mod campaigns; +pub(crate) mod items; pub(crate) mod lessons; pub(crate) mod scenarios; pub(crate) mod sections; @@ -21,6 +23,7 @@ use assets::BaseContentAssets; /// Complete built-in content before it is wrapped and serialized as mod items. pub(crate) struct BaseContent { + pub(crate) items: Vec, pub(crate) sections: Vec, pub(crate) ships: Vec, pub(crate) scenarios: Vec, @@ -33,6 +36,7 @@ pub(crate) struct BaseContent { pub(crate) fn build() -> BaseContent { let assets = BaseContentAssets::from_paths(); BaseContent { + items: items::item_catalog(), sections: sections::section_catalog(&assets), ships: ships::ship_catalog(&assets), scenarios: scenarios::catalog(&assets), diff --git a/crates/nova_authoring/src/base_content/scenarios/main_menu/duel.rs b/crates/nova_authoring/src/base_content/scenarios/main_menu/duel.rs index 5e0946a52..f3346bbf1 100644 --- a/crates/nova_authoring/src/base_content/scenarios/main_menu/duel.rs +++ b/crates/nova_authoring/src/base_content/scenarios/main_menu/duel.rs @@ -259,7 +259,7 @@ fn duelist( lootable: false, // Equal reserves keep the duel even: each magazine refills from // its own hold, and a dry duelist fights on with what is loaded. - inventory: ShipInventoryStock::new([(ItemType::PdcRound, 2_000)]), + inventory: ShipInventoryStock::new([(ITEM_PDC_ROUND.into(), 2_000)]), credits: 2_000, ..Default::default() }), diff --git a/crates/nova_authoring/src/base_content/scenarios/open_world.rs b/crates/nova_authoring/src/base_content/scenarios/open_world.rs index cf8c0e3d7..e3efbba5c 100644 --- a/crates/nova_authoring/src/base_content/scenarios/open_world.rs +++ b/crates/nova_authoring/src/base_content/scenarios/open_world.rs @@ -12,7 +12,9 @@ use std::collections::BTreeMap; use bevy::prelude::*; use nova_events::prelude::*; -use nova_gameplay::prelude::{ItemType, ShipInventoryStock}; +use nova_gameplay::prelude::{ + ShipInventoryStock, ITEM_HULL_PLATE, ITEM_PDC_ROUND, ITEM_RAIL_SLUG, ITEM_TORPEDO, +}; use nova_input::prelude::InputSource; use nova_scenario::prelude::*; use nova_ship::prelude::ShipCapabilities; @@ -90,10 +92,10 @@ fn player() -> ScenarioObjectConfig { // Plates for repair and a finite reserve for every idle reload: // point defense, railgun and torpedo bays. 3,520 kg in all. inventory: ShipInventoryStock::new([ - (ItemType::HullPlate, 12), - (ItemType::PdcRound, 6000), - (ItemType::RailSlug, 20), - (ItemType::Torpedo, 12), + (ITEM_HULL_PLATE.into(), 12), + (ITEM_PDC_ROUND.into(), 6000), + (ITEM_RAIL_SLUG.into(), 20), + (ITEM_TORPEDO.into(), 12), ]), lootable: false, credits: 2_000, diff --git a/crates/nova_authoring/src/base_content/scenarios/tutorial/range.rs b/crates/nova_authoring/src/base_content/scenarios/tutorial/range.rs index 454787c2f..66bf5c9f5 100644 --- a/crates/nova_authoring/src/base_content/scenarios/tutorial/range.rs +++ b/crates/nova_authoring/src/base_content/scenarios/tutorial/range.rs @@ -139,7 +139,7 @@ pub(crate) fn trainer_with(capabilities: ShipCapabilities) -> ScenarioObjectConf // `PLANETOID_MASS`): an intentional stationary start. initial_velocity: MetersPerSecond3::ZERO, // The gun's idle reload draws on this finite reserve. - inventory: ShipInventoryStock::new([(ItemType::PdcRound, 2000)]), + inventory: ShipInventoryStock::new([(ITEM_PDC_ROUND.into(), 2000)]), lootable: false, credits: 2_000, }), diff --git a/crates/nova_authoring/src/generation.rs b/crates/nova_authoring/src/generation.rs index 970077a85..76148b4dc 100644 --- a/crates/nova_authoring/src/generation.rs +++ b/crates/nova_authoring/src/generation.rs @@ -11,6 +11,7 @@ //! build - `nova_modding` (a dependency) turns on `nova_scenario/serde`, and //! Cargo feature unification carries it here. +use nova_gameplay::prelude::ItemDesign; use nova_modding::prelude::Content; use nova_scenario::prelude::{ CampaignConfig, ScenarioConfig, ShipDesignPrototype, ShipDesignSource, SpaceshipConfig, @@ -27,10 +28,10 @@ use crate::base_content; /// parity test asserts. pub mod prelude { pub use super::{ - build_campaign_content, build_campaigns, build_lesson_content, build_lessons, - build_scenario_contents, build_scenarios, build_section_catalog, build_section_content, - build_ship_content, build_ships, build_style_content, build_styles, content_files, - serialize_content, spawned_ship_sections, + build_campaign_content, build_campaigns, build_item_content, build_items, + build_lesson_content, build_lessons, build_scenario_contents, build_scenarios, + build_section_catalog, build_section_content, build_ship_content, build_ships, + build_style_content, build_styles, content_files, serialize_content, spawned_ship_sections, }; } @@ -70,6 +71,20 @@ pub fn build_campaign_content() -> Vec { .collect() } +/// The base game's items, in the order the file carries them. +pub fn build_items() -> Vec { + base_content::build().items +} + +/// The item catalog wrapped as one `Vec` of `Content::Item` items - +/// the shape the committed `assets/base/items/base.content.ron` file carries. +/// +/// ONE file for every item, like the styles: an item is a few numbers, and a +/// price or a mass is read against the others. +pub fn build_item_content() -> Vec { + build_items().into_iter().map(Content::Item).collect() +} + /// The base game's skin styles, in a stable order - the look a ship's derived /// cladding wears, named by id from its config. pub fn build_styles() -> Vec { @@ -190,6 +205,10 @@ pub fn serialize_content(content: &[Content]) -> String { /// what it contains. pub fn content_files() -> Vec<(String, String)> { let mut files = vec![ + ( + "base/items/base.content.ron".to_string(), + serialize_content(&build_item_content()), + ), ( "base/sections/base.content.ron".to_string(), serialize_content(&build_section_content()), diff --git a/crates/nova_authoring/src/lint_walk.rs b/crates/nova_authoring/src/lint_walk.rs index 3e42949d0..b07785b8d 100644 --- a/crates/nova_authoring/src/lint_walk.rs +++ b/crates/nova_authoring/src/lint_walk.rs @@ -9,8 +9,13 @@ use std::{ path::{Path, PathBuf}, }; +use nova_assets::items::{item_pack_faults, ItemPack}; +use nova_gameplay::prelude::{GameItems, ItemDesign}; use nova_input::prelude::InputSource; -use nova_mod_format::{BundleManifest, BASE_MOD_ID}; +use nova_mod_format::{ + deps::{transitive_deps, DepGraph}, + BundleManifest, BASE_MOD_ID, +}; use nova_modding::prelude::Content; use nova_scenario::prelude::{ lint_campaign, lint_scenario, lint_ship_design_config, CampaignConfig, EventActionConfig, @@ -46,6 +51,7 @@ pub mod prelude { struct WalkedBundle { id: String, manifest: BundleManifest, + items: Vec, sections: Vec, ships: Vec, scenarios: Vec, @@ -109,6 +115,7 @@ fn read_bundle(id: &str, dir: &Path) -> WalkedBundle { .unwrap_or_else(|err| panic!("parse {}: {err}", path.display())); content.extend(items.into_iter().map(|item| (rel.clone(), item))); } + let mut items = Vec::new(); let mut sections = Vec::new(); let mut ships = Vec::new(); let mut scenarios = Vec::new(); @@ -117,6 +124,7 @@ fn read_bundle(id: &str, dir: &Path) -> WalkedBundle { let mut ui_themes = Vec::new(); for (_, item) in &content { match item { + Content::Item(design) => items.push(design.clone()), Content::Section(section) => sections.push(section.as_ref().clone()), Content::Ship(ship) => ships.push(ship.clone()), Content::Scenario(scenario) => scenarios.push(scenario.clone()), @@ -132,6 +140,7 @@ fn read_bundle(id: &str, dir: &Path) -> WalkedBundle { WalkedBundle { id: id.to_string(), manifest, + items, sections, ships, scenarios, @@ -267,9 +276,23 @@ fn lint_bundle(bundle: &WalkedBundle, all: &[WalkedBundle]) -> Vec<(String, Lint visible_ships.extend(bundle.ships.iter()); let known_ships = KnownShipDesigns::from_configs(visible_ships); + // Visible items: the catalog the merge builds when this bundle loads with + // what it depends on, a dependent's replacement winning. + let known_items = GameItems::new( + effective_item_bundles(bundle, all) + .into_iter() + .flat_map(|walked| walked.items.iter().cloned()), + ); + let mut issues = Vec::new(); for scenario in &bundle.scenarios { - for issue in lint_scenario(scenario, &known_sections, &known_ships, &known_scenarios) { + for issue in lint_scenario( + scenario, + &known_sections, + &known_ships, + &known_scenarios, + &known_items, + ) { issues.push((bundle.id.clone(), issue)); } } @@ -561,6 +584,39 @@ pub fn repo_ship_part_packs(ids: &[&str]) -> Vec { packs.into_values().collect() } +/// The bundles whose items load with `bundle`: base, every bundle it depends +/// on directly or through another, and itself, each after every bundle it +/// depends on, so a later item replaces an earlier one as the merge does. A +/// dependency missing from the walk is left out; a chain too deep to walk +/// keeps only base and the bundle, and [`item_pack_faults`] names it. +fn effective_item_bundles<'a>( + bundle: &WalkedBundle, + all: &'a [WalkedBundle], +) -> Vec<&'a WalkedBundle> { + let graph: DepGraph = all + .iter() + .map(|walked| (walked.id.clone(), walked.manifest.meta.dependencies.clone())) + .collect(); + let reach = |id: &str| -> BTreeSet { + let mut deps: BTreeSet = transitive_deps(&graph, id) + .unwrap_or_default() + .into_iter() + .collect(); + if id != BASE_MOD_ID { + deps.insert(BASE_MOD_ID.to_string()); + } + deps + }; + let deps = reach(&bundle.id); + let mut bundles: Vec<&WalkedBundle> = all + .iter() + .filter(|walked| walked.id == bundle.id || deps.contains(&walked.id)) + .collect(); + // A bundle reaches strictly more than any bundle it depends on. + bundles.sort_by_cached_key(|walked| reach(&walked.id).len()); + bundles +} + /// `bundle`'s effective section catalog as snapshot packs: base, every bundle /// it depends on directly or through another, and itself. `base` is every /// mod's implicit dependency. A dependency missing from the walk is left for @@ -756,6 +812,49 @@ fn build_report( }); } + // 1c. Items: the rule the merge applies, over every walked pack at once, + // since a player may enable any of them together and the merge then + // refuses both packs of an unrelated duplicate. A fault is reported against + // the pack the rule refuses, when a reported bundle loads that pack. + // This is stricter than the merge, which sees only the enabled packs: two + // repo packs that are never enabled together still fail here, and a + // `--target` mod is checked against every repo pack. A check of each + // bundle's own dependency set would never report an unrelated duplicate. + let loaded: HashSet<&str> = all + .iter() + .filter(|b| report_ids.contains(&b.id)) + .flat_map(|bundle| effective_item_bundles(bundle, all)) + .map(|walked| walked.id.as_str()) + .collect(); + let packs: Vec = all + .iter() + .map(|walked| ItemPack { + id: walked.id.as_str(), + dependencies: walked.manifest.meta.dependencies.as_slice(), + items: walked.items.iter().collect(), + }) + .collect(); + let item_faults: Vec<(String, String)> = item_pack_faults(&packs) + .into_iter() + .filter(|(pack, _)| loaded.contains(pack.as_str())) + .flat_map(|(pack, messages)| { + messages + .into_iter() + .map(move |message| (pack.clone(), message)) + }) + .collect(); + for (bundle, message) in item_faults { + findings.push(Finding { + file: None, + bundle, + severity: ReportSeverity::Error, + category: Category::Reference, + element: "items".to_string(), + message, + suggestion: None, + }); + } + // 2. Balance / fairness audit (nova_authoring::balance), acks applied. let audit_bundles: Vec = all .iter() @@ -944,13 +1043,14 @@ pub fn collect_target(dir: &Path) -> ContentReport { #[cfg(test)] mod tests { - use nova_gameplay::prelude::AssetRef; + use nova_gameplay::prelude::{AssetRef, ItemCategoryType, ItemDesign, ItemDesignId}; use nova_mod_format::{BundleManifest, ModMeta}; use nova_modding::prelude::Content; use nova_scenario::prelude::ScenarioConfig; use nova_world_base::prelude::{ShipPartFamilyType, ShipPartFault}; use super::{build_report, lint_bundle, scenario_input_overlaps, ReportSeverity, WalkedBundle}; + use crate::base_content::items::item_catalog; fn scenario(id: &str, cubemap: &str) -> Content { Content::Scenario(ScenarioConfig { @@ -1017,6 +1117,13 @@ mod tests { _ => None, }) .collect(); + let items = content + .iter() + .filter_map(|c| match c { + Content::Item(design) => Some(design.clone()), + _ => None, + }) + .collect(); WalkedBundle { id: id.to_string(), manifest: BundleManifest { @@ -1028,6 +1135,7 @@ mod tests { }, new_game_scenario: None, }, + items, sections, ships, scenarios, @@ -1059,6 +1167,52 @@ mod tests { /// A generated-ship part fault belongs to the pack that authored the bad /// definition. A `--target` lint must not blame the target for a base /// stat, and a whole-tree lint must not repeat the base fault per mod. + /// Two bundles that each define one item id, with no dependency between + /// them, are both reported, as the merge refuses both when a player + /// enables them together. + #[test] + fn two_unrelated_bundles_defining_one_item_are_both_reported() { + let core = || { + Content::Item(ItemDesign { + id: ItemDesignId::from("core"), + name: "Core".to_string(), + about: "A fixture item.".to_string(), + category: ItemCategoryType::Parts, + mass_g: 1_000, + ask_cr: 10, + bid_cr: 5, + }) + }; + let all = vec![ + walked( + "base", + &[], + &[], + item_catalog().into_iter().map(Content::Item).collect(), + ), + walked("a", &[], &[], vec![core()]), + walked("b", &[], &[], vec![core()]), + ]; + let report_ids = ["a", "b"].into_iter().map(str::to_string).collect(); + let mut reported: Vec<(String, String)> = build_report(&all, &report_ids, None) + .findings + .into_iter() + .filter(|finding| { + finding.element == "items" && finding.severity == ReportSeverity::Error + }) + .map(|finding| (finding.bundle, finding.message)) + .collect(); + reported.sort(); + let message = "item 'core' is defined by 'a' and 'b', and neither depends on the other"; + assert_eq!( + reported, + [ + ("a".to_string(), message.to_string()), + ("b".to_string(), message.to_string()), + ] + ); + } + #[test] fn a_ship_part_stat_fault_is_reported_once_against_its_authoring_bundle() { let hull = |id: &str, health: f32| -> Content { diff --git a/crates/nova_bench/scenarios/arsenal.content.ron b/crates/nova_bench/scenarios/arsenal.content.ron index 21c06f62b..aac1ace45 100644 --- a/crates/nova_bench/scenarios/arsenal.content.ron +++ b/crates/nova_bench/scenarios/arsenal.content.ron @@ -149,7 +149,7 @@ "pdc_ventral": [Keyboard(KeyS)], }, )), - inventory: {PdcRound: 1000, RailSlug: 10, Torpedo: 2}, + inventory: {"PdcRound": 1000, "RailSlug": 10, "Torpedo": 2}, lootable: false, credits: 2_000, )), diff --git a/crates/nova_bench/scenarios/docking_warship_tender.content.ron b/crates/nova_bench/scenarios/docking_warship_tender.content.ron index fc48edc8c..bd0d73a76 100644 --- a/crates/nova_bench/scenarios/docking_warship_tender.content.ron +++ b/crates/nova_bench/scenarios/docking_warship_tender.content.ron @@ -60,7 +60,7 @@ controller: Player(( input_mapping: {}, )), - inventory: {PdcRound: 6000, RailSlug: 20, Torpedo: 12}, + inventory: {"PdcRound": 6000, "RailSlug": 20, "Torpedo": 12}, lootable: false, credits: 2_000, )), diff --git a/crates/nova_bench/scenarios/hunt.content.ron b/crates/nova_bench/scenarios/hunt.content.ron index 625a4412e..c963f65a8 100644 --- a/crates/nova_bench/scenarios/hunt.content.ron +++ b/crates/nova_bench/scenarios/hunt.content.ron @@ -41,7 +41,7 @@ "pdc_ventral_starboard": [Keyboard(KeyM)], }, )), - inventory: {PdcRound: 2000}, + inventory: {"PdcRound": 2000}, lootable: false, credits: 2_000, )), diff --git a/crates/nova_bench/scenarios/range.content.ron b/crates/nova_bench/scenarios/range.content.ron index 07cdfca60..dc52f31fc 100644 --- a/crates/nova_bench/scenarios/range.content.ron +++ b/crates/nova_bench/scenarios/range.content.ron @@ -64,7 +64,7 @@ "pdc_ventral_starboard": [Keyboard(KeyM)], }, )), - inventory: {PdcRound: 2000}, + inventory: {"PdcRound": 2000}, lootable: false, credits: 2_000, )), diff --git a/crates/nova_bench/scenarios/slingshot.content.ron b/crates/nova_bench/scenarios/slingshot.content.ron index 6c95dc2c9..6e13043f4 100644 --- a/crates/nova_bench/scenarios/slingshot.content.ron +++ b/crates/nova_bench/scenarios/slingshot.content.ron @@ -50,7 +50,7 @@ "pdc_ventral_starboard": [Keyboard(KeyM)], }, )), - inventory: {PdcRound: 2000}, + inventory: {"PdcRound": 2000}, lootable: false, credits: 2_000, )), diff --git a/crates/nova_command/src/commands.rs b/crates/nova_command/src/commands.rs index 55bdc802f..983e2ff61 100644 --- a/crates/nova_command/src/commands.rs +++ b/crates/nova_command/src/commands.rs @@ -109,6 +109,8 @@ pub mod live { pub const COMMAND: &str = "command"; /// The labels the Map pane's live contact list is showing. pub const CONTACT: &str = "contact"; + /// Item ids in the loaded item catalog, mod items included. + pub const ITEM: &str = "item"; /// Every token, so [`noun`] can be pinned against the list by test. pub const ALL: &[&str] = &[ @@ -121,6 +123,7 @@ pub mod live { SOURCE, COMMAND, CONTACT, + ITEM, ]; /// What the player is being ASKED for, as `help` phrases it. Tab is what @@ -136,6 +139,7 @@ pub mod live { SOURCE => "a key, mouse button or pad button", COMMAND => "a command name", CONTACT => "a contact label on the map", + ITEM => "an item id", _ => UNNAMED, } } @@ -144,22 +148,6 @@ pub mod live { pub const UNNAMED: &str = "a value the world knows"; } -/// The item ids `item give` accepts: the `ItemType` names content writes. -/// This crate has no gameplay dependency; a `nova_console` cheats test pins -/// the dispatcher's parse against this list and `ItemType`. -const ITEM_WORDS: &[&str] = &[ - "HullPlate", - "PdcRound", - "RailSlug", - "Torpedo", - "StoneOre", - "IronOre", - "WaterIce", - "CarbonOre", - "Rations", - "SalvagedParts", -]; - /// The class words `commands [class]` accepts. Pinned against /// [`CommandClass::ALL`] by test, so a new class cannot go uncompleted. const CLASS_WORDS: &[&str] = &["utility", "readonly", "setting", "cheat"]; @@ -495,7 +483,7 @@ pub const COMMAND_CATALOG: &[CommandSpec] = &[ arg_hint: Some(" "), args: &[ CommandArg::Live(live::SHIP), - CommandArg::Words(ITEM_WORDS), + CommandArg::Live(live::ITEM), CommandArg::Free, ], examples: &["item give player_spaceship HullPlate 12"], diff --git a/crates/nova_console/Cargo.toml b/crates/nova_console/Cargo.toml index 57410d90a..6f360fd71 100644 --- a/crates/nova_console/Cargo.toml +++ b/crates/nova_console/Cargo.toml @@ -17,6 +17,9 @@ nova_interface = { path = "../nova_interface" } nova_scenario = { path = "../nova_scenario" } nova_ship = { path = "../nova_ship" } +[dev-dependencies] +nova_gameplay = { path = "../nova_gameplay", features = ["test-support"] } + [lints] workspace = true diff --git a/crates/nova_console/src/cheats.rs b/crates/nova_console/src/cheats.rs index af00f2f6a..b206dd752 100644 --- a/crates/nova_console/src/cheats.rs +++ b/crates/nova_console/src/cheats.rs @@ -138,14 +138,20 @@ pub fn ammo_refill_section(world: &mut World, ship_id: &str, section_id: &str) - /// free hold, or none of it is added. pub fn item_give(world: &mut World, ship_id: &str, item_id: &str, quantity: &str) -> Resolved { const NAME: &str = "item give"; - let Some(item) = item_type(item_id) else { - let known = command_spec(NAME) - .and_then(|spec| spec.args.get(1)) - .map_or_else(String::new, |arg| arg.words().join(", ")); + let Some(items) = world.get_resource::().cloned() else { return Err(CommandResult::error( NAME, Some(CLASS), - format!("{NAME}: no item named '{item_id}' ({known})"), + format!("{NAME}: no item catalog is loaded; the world is inconsistent"), + )); + }; + let item = ItemDesignId::from(item_id); + let Some(design) = items.get(&item).cloned() else { + let known: Vec<&str> = items.iter().map(|design| design.id.as_str()).collect(); + return Err(CommandResult::error( + NAME, + Some(CLASS), + format!("{NAME}: no item named '{item_id}' ({})", known.join(", ")), )); }; let Some(count) = quantity.parse::().ok().filter(|count| *count > 0) else { @@ -165,7 +171,11 @@ pub fn item_give(world: &mut World, ship_id: &str, item_id: &str, quantity: &str format!("'{ship_id}' has no inventory; the world is inconsistent"), )); }; - let (label, mass_g, free_g) = (item.label(), item.stack_mass_g(count), inventory.free_g()); + let (label, mass_g, free_g) = ( + design.name.as_str(), + design.stack_mass_g(count), + inventory.free_g(&items), + ); if mass_g > u64::from(free_g) { return Err(CommandResult::refused( NAME, @@ -177,11 +187,11 @@ pub fn item_give(world: &mut World, ship_id: &str, item_id: &str, quantity: &str ), )); } - inventory.add(item, count); - let held = inventory.count(item); + inventory.add(&items, &item, count); + let held = inventory.count(&item); let load = format!( "{} / {}", - kg_text(u64::from(inventory.used_g())), + kg_text(u64::from(inventory.used_g(&items))), kg_text(u64::from(inventory.capacity_g())), ); Ok(CommandResult::ok( @@ -194,24 +204,6 @@ pub fn item_give(world: &mut World, ship_id: &str, item_id: &str, quantity: &str ))])) } -/// The item a typed id names. The ids are the `ItemType` names content -/// writes, so a player types what a scenario file says. -fn item_type(id: &str) -> Option { - match id { - "HullPlate" => Some(ItemType::HullPlate), - "PdcRound" => Some(ItemType::PdcRound), - "RailSlug" => Some(ItemType::RailSlug), - "Torpedo" => Some(ItemType::Torpedo), - "StoneOre" => Some(ItemType::StoneOre), - "IronOre" => Some(ItemType::IronOre), - "WaterIce" => Some(ItemType::WaterIce), - "CarbonOre" => Some(ItemType::CarbonOre), - "Rations" => Some(ItemType::Rations), - "SalvagedParts" => Some(ItemType::SalvagedParts), - _ => None, - } -} - /// `scenario load `: abandon the attempt and start a fresh one. /// /// Utility, not Cheat: it decides nothing about how the abandoned attempt ended. @@ -335,13 +327,15 @@ mod tests { cheats.arm(); } world.insert_resource(cheats); + let items = nova_gameplay::test_support::test_items(); let ship = world .spawn(( SpaceshipRootMarker, EntityId("player_spaceship".to_string()), - ShipInventory::new(400_000, [(ItemType::HullPlate, 12)]), + ShipInventory::new(&items, 400_000, [(ItemDesignId::from(ITEM_HULL_PLATE), 12)]), )) .id(); + world.insert_resource(items); (world, ship) } @@ -363,29 +357,6 @@ mod tests { let (mut world, ship) = world_with_hold(true); let hold = |world: &World| world.get::(ship).expect("hold").clone(); - // The catalog's item words parse to their own `ItemType` names. The - // match stops compiling when a variant is added, which sends its author - // to `ITEM_WORDS` and `item_type`. - let id = |item: ItemType| match item { - ItemType::HullPlate => "HullPlate", - ItemType::PdcRound => "PdcRound", - ItemType::RailSlug => "RailSlug", - ItemType::Torpedo => "Torpedo", - ItemType::StoneOre => "StoneOre", - ItemType::IronOre => "IronOre", - ItemType::WaterIce => "WaterIce", - ItemType::CarbonOre => "CarbonOre", - ItemType::Rations => "Rations", - ItemType::SalvagedParts => "SalvagedParts", - }; - let words = command_spec("item give").expect("catalog row").args[1].words(); - let parsed: Vec<&str> = words - .iter() - .filter_map(|word| item_type(word)) - .map(id) - .collect(); - assert_eq!(parsed, words); - let result = give(&mut world, ["player_spaceship", "HullPlate", "5"]); assert_eq!(result.status, CommandStatus::Ok, "{}", result.detail); assert_eq!(result.detail, "player_spaceship: +5 Hull plate, 17 held"); @@ -398,18 +369,19 @@ mod tests { let result = give(&mut world, ["player_spaceship", id, count]); assert_eq!(result.status, CommandStatus::Ok, "{id}: {}", result.detail); } + let items = world.resource::().clone(); let hold = hold(&world); assert_eq!( hold.stacks().collect::>(), [ - (ItemType::HullPlate, 17), - (ItemType::PdcRound, 300), - (ItemType::RailSlug, 1), - (ItemType::Torpedo, 1), + (&ItemDesignId::from(ITEM_HULL_PLATE), 17), + (&ItemDesignId::from(ITEM_PDC_ROUND), 300), + (&ItemDesignId::from(ITEM_RAIL_SLUG), 1), + (&ItemDesignId::from(ITEM_TORPEDO), 1), ] ); - assert_eq!(hold.used_g(), 400_000); - assert_eq!(hold.free_g(), 0); + assert_eq!(hold.used_g(&items), 400_000); + assert_eq!(hold.free_g(&items), 0); } /// A give that is unarmed, names no single live ship, names an unknown @@ -435,7 +407,7 @@ mod tests { ( ["player_spaceship", "hullplate", "1"], CommandStatus::Error, - "no item named 'hullplate' (HullPlate, PdcRound, RailSlug, Torpedo, StoneOre, IronOre, WaterIce, CarbonOre, Rations, SalvagedParts)", + "no item named 'hullplate' (CarbonOre, HullPlate, IronOre, PdcRound, RailSlug, Rations, SalvagedParts, StoneOre, Torpedo, WaterIce)", ), ( ["player_spaceship", "HullPlate", "0"], diff --git a/crates/nova_console/src/completion.rs b/crates/nova_console/src/completion.rs index f1eeb8988..cfe270ad8 100644 --- a/crates/nova_console/src/completion.rs +++ b/crates/nova_console/src/completion.rs @@ -24,6 +24,7 @@ pub(crate) fn publish_live_values( q_ships: Query<(Entity, &EntityId), With>, q_sections: Query<(&ChildOf, &EntityId), With>, scenarios: Option>, + items: Option>, events: Option>, bindings: Option>, ) { @@ -62,6 +63,13 @@ pub(crate) fn publish_live_values( ids.sort_unstable(); entries.push((live::SCENARIO.to_string(), ids)); } + if let Some(items) = items { + let ids: Vec = items + .iter() + .map(|item| item.id.as_str().to_string()) + .collect(); + entries.push((live::ITEM.to_string(), ids)); + } if let Some(events) = events { let mut names: Vec = events.variables().map(|(key, _)| key.clone()).collect(); names.sort_unstable(); diff --git a/crates/nova_console/src/inspect.rs b/crates/nova_console/src/inspect.rs index 248a93174..44df98f0b 100644 --- a/crates/nova_console/src/inspect.rs +++ b/crates/nova_console/src/inspect.rs @@ -142,7 +142,7 @@ pub fn section(world: &mut World, ship_id: &str, id: &str) -> Resolved { let entity_ref = world.entity(entity); let health = entity_ref.get::().cloned(); let ammo = entity_ref.get::().copied(); - let reload = entity_ref.get::().copied(); + let reload = entity_ref.get::().cloned(); let unlimited = entity_ref.get::().is_some(); let kind = section_kind_label(world, entity); Ok( diff --git a/crates/nova_core/tests/world_resume_refusal.rs b/crates/nova_core/tests/world_resume_refusal.rs index 8fe1a0c8c..45281874d 100644 --- a/crates/nova_core/tests/world_resume_refusal.rs +++ b/crates/nova_core/tests/world_resume_refusal.rs @@ -20,8 +20,9 @@ use bevy_rand::prelude::{EntropyPlugin, WyRand}; use nova_assets::prelude::LoadedSectionPacks; use nova_core::loading_screen::LoadingScreenPlugin; use nova_gameplay::prelude::{ - resumed_lifetime, AssetRef, ClockFreeze, DamageType, FreezeOwner, FrozenRoundFlight, GameMode, - GameStates, PauseStates, PlayerSpaceshipMarker, ProjectileDamage, SavedLifetime, SavedOwner, + resumed_lifetime, AssetRef, ClockFreeze, DamageType, FreezeOwner, FrozenRoundFlight, GameItems, + GameMode, GameStates, PauseStates, PlayerSpaceshipMarker, ProjectileDamage, SavedLifetime, + SavedOwner, }; use nova_menu::prelude::NovaMenuPlugin; use nova_scenario::prelude::{CurrentScenario, GameScenarios, ScenarioConfig}; @@ -305,7 +306,12 @@ fn a_load_that_cannot_fill_its_window_is_refused_and_releases_the_clocks() { // The lock is free: a fresh open succeeds (and is dropped at once). { - let opened = open_world(&worlds_root, &slug, &packs); + let opened = open_world( + &worlds_root, + &slug, + &packs, + app.world().resource::(), + ); assert!(opened.is_ok(), "world.lock is free: {opened:?}"); } @@ -518,7 +524,12 @@ fn a_load_that_cannot_fill_its_window_is_refused_and_releases_the_clocks() { // The lock is free: a fresh open succeeds (and is dropped at once). { - let opened = open_world(&worlds_root, &slug2, &packs); + let opened = open_world( + &worlds_root, + &slug2, + &packs, + app.world().resource::(), + ); assert!(opened.is_ok(), "world.lock is free: {opened:?}"); } diff --git a/crates/nova_editor/src/event.rs b/crates/nova_editor/src/event.rs index b32c74c90..46e60f019 100644 --- a/crates/nova_editor/src/event.rs +++ b/crates/nova_editor/src/event.rs @@ -2223,7 +2223,7 @@ mod tests; mod name_coverage { use std::collections::HashSet; - use nova_gameplay::prelude::AssetRef; + use nova_gameplay::prelude::{AssetRef, GameItems}; use nova_scenario::prelude::*; use super::{ActionChoice, ActionChoiceExt, ActionKind}; @@ -2280,6 +2280,7 @@ mod name_coverage { &KnownSections::default(), &KnownShipDesigns::default(), &HashSet::new(), + &GameItems::default(), ); let dangling = issues .iter() diff --git a/crates/nova_gameplay/Cargo.toml b/crates/nova_gameplay/Cargo.toml index 2548b22ba..050d5fa75 100644 --- a/crates/nova_gameplay/Cargo.toml +++ b/crates/nova_gameplay/Cargo.toml @@ -18,7 +18,7 @@ bevy = { version = "0.19.0", features = ["wav"] } noise = { version = "0.9" } rand = { version = "0.10.2" } bevy_hanabi = { version = "0.19.0", default-features = false, features = ["2d", "3d"] } -serde = { version = "1", features = ["derive"], optional = true } +serde = { version = "1", features = ["derive", "rc"], optional = true } bevy_rand = { version = "0.15.0", default-features = false, features = ["chacha20", "wyrand"] } # The interpolation crate behind avian's TransformInterpolation. Direct dep # only for the easing-state components (avian re-exports the markers but not diff --git a/crates/nova_gameplay/src/inventory.rs b/crates/nova_gameplay/src/inventory.rs index fc8bbc24e..63b0f6203 100644 --- a/crates/nova_gameplay/src/inventory.rs +++ b/crates/nova_gameplay/src/inventory.rs @@ -1,12 +1,18 @@ -//! What a ship carries: a closed set of item types, the category each belongs -//! to, the per-ship stack counts and the mass the ship has room for. +//! What a ship carries: the authored item catalog, the category each item +//! belongs to, the per-ship stack counts and the mass the ship has room for. +//! +//! Items are content. Each [`ItemDesign`] is authored in RON and keyed by an +//! [`ItemDesignId`]; the mod merge publishes the effective catalog as +//! [`GameItems`], and every mass, price and name comes from it. Engine code +//! names only the role items in [`ITEM_ROLES`], which content lint and the +//! merge require with their categories. //! //! Every ship root requires a [`ShipInventory`]. Its `Default` has no room and //! no stock: a code-built ship that never states an inventory carries nothing. //! An authored ship states its stock as a [`ShipInventoryStock`] through //! `SpaceshipConfig::inventory`, which RON requires; the spawn consumes that //! stock into a `ShipInventory` whose capacity its design derives. A Ship pane -//! repair spends [`ItemType::HullPlate`] by the [`plan_plate_repair`] rule, an +//! repair spends [`ITEM_HULL_PLATE`] by the [`plan_plate_repair`] rule, an //! Inventory pane transfer moves items between two docked ships by the //! [`plan_item_transfer`] rule, and a jettison queues [`CargoCanister`]s by the //! [`plan_item_jettison`] rule, a weapon's idle reload moves its ammunition @@ -17,7 +23,7 @@ //! while its count is above zero, and the mass of all stacks never passes the //! capacity. Mass is counted in grams; [`kg_text`] shows it in kilograms. -use std::collections::BTreeMap; +use std::{collections::BTreeMap, sync::Arc}; use bevy::prelude::*; @@ -28,125 +34,160 @@ pub mod prelude { pub use super::{ kg_text, plan_credit_take, plan_item_jettison, plan_item_trade, plan_item_transfer, plan_plate_repair, CargoCanister, CargoCanisterIdAllocator, CargoCanisterRuntimeId, - CreditTakeRefusalType, ItemCategoryType, ItemJettison, ItemJettisonRefusalType, ItemTrade, - ItemTradeRefusalType, ItemTradeType, ItemTransferRefusalType, ItemTransferType, ItemType, - LootableShipMarker, PlateRepair, PlateRepairRefusalType, ShipCredits, ShipInventory, - ShipInventoryStock, CARGO_CANISTER_MAX_MASS_G, HULL_PLATE_HEALTH, + CreditTakeRefusalType, GameItems, ItemCategoryType, ItemDesign, ItemDesignId, ItemJettison, + ItemJettisonRefusalType, ItemTrade, ItemTradeRefusalType, ItemTradeType, + ItemTransferRefusalType, ItemTransferType, LootableShipMarker, PlateRepair, + PlateRepairRefusalType, ShipCredits, ShipInventory, ShipInventoryStock, + CARGO_CANISTER_MAX_MASS_G, HULL_PLATE_HEALTH, ITEM_CARBON_ORE, ITEM_HULL_PLATE, + ITEM_IRON_ORE, ITEM_PDC_ROUND, ITEM_RAIL_SLUG, ITEM_RATIONS, ITEM_ROLES, + ITEM_SALVAGED_PARTS, ITEM_STONE_ORE, ITEM_TORPEDO, ITEM_WATER_ICE, }; } -/// An item a ship can carry. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Reflect)] +/// The id an item is referenced by from stock, cargo, a save or a command. +/// +/// An authored catalog id, resolved against [`GameItems`]. Serialized as the +/// bare id string, so RON writes `{"PdcRound": 2000}`. An id no loaded item +/// carries is refused at lint and at load; nothing resolves it to another +/// item. Shared rather than owned, so the many copies a hold, a reload and a +/// canister keep cost no allocation. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Reflect)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum ItemType { - /// Hull plating stock, counted in plates. - HullPlate, - /// One point-defense round. Kinetic and Pierce mounts load the same round; - /// the mount decides its damage type. - PdcRound, - /// One railgun slug. - RailSlug, - /// One torpedo. Every bay type loads the same torpedo; the bay decides its - /// flight. - Torpedo, - /// Ore mined from a rock-kind asteroid. - StoneOre, - /// Ore mined from a metal-kind asteroid. - IronOre, - /// Ice mined from an ice-kind asteroid. - WaterIce, - /// Ore mined from a carbon-kind asteroid. - CarbonOre, - /// Packed provisions for trade. Nothing eats them. - Rations, - /// Scavenged machine parts for trade. - SalvagedParts, -} +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct ItemDesignId(Arc); -impl ItemType { - /// Fixed mass of one item in grams. Grams keep capacity and conservation - /// arithmetic exact for items lighter than a kilogram. - pub fn mass_g(self) -> u32 { - match self { - Self::HullPlate => 10_000, - Self::PdcRound => 200, - Self::RailSlug => 20_000, - Self::Torpedo => 150_000, - Self::StoneOre | Self::IronOre | Self::WaterIce | Self::CarbonOre => 10_000, - Self::Rations => 2_000, - Self::SalvagedParts => 25_000, - } +impl ItemDesignId { + /// The id as authored. + pub fn as_str(&self) -> &str { + &self.0 } +} - /// Credits a trader asks for one item: what a Buy pays. Provisional - /// values, not a balance decision. - pub fn ask_cr(self) -> u32 { - match self { - Self::HullPlate => 40, - Self::PdcRound => 4, - Self::RailSlug => 40, - Self::Torpedo => 400, - Self::StoneOre => 4, - Self::IronOre => 16, - Self::WaterIce => 12, - Self::CarbonOre => 8, - Self::Rations => 8, - Self::SalvagedParts => 120, - } +impl From<&str> for ItemDesignId { + fn from(id: &str) -> Self { + Self(id.into()) } +} - /// Credits a trader bids for one item: what a Sell earns. Below - /// [`ask_cr`](Self::ask_cr), so buying and selling back loses credits. - pub fn bid_cr(self) -> u32 { - match self { - Self::HullPlate => 30, - Self::PdcRound => 3, - Self::RailSlug => 30, - Self::Torpedo => 300, - Self::StoneOre => 3, - Self::IronOre => 12, - Self::WaterIce => 9, - Self::CarbonOre => 6, - Self::Rations => 6, - Self::SalvagedParts => 90, - } +impl std::fmt::Display for ItemDesignId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) } +} +/// Hull plating stock, counted in plates. The repair item. +pub const ITEM_HULL_PLATE: &str = "HullPlate"; +/// One point-defense round. Kinetic and Pierce mounts load the same round; +/// the mount decides its damage type. +pub const ITEM_PDC_ROUND: &str = "PdcRound"; +/// One railgun slug. +pub const ITEM_RAIL_SLUG: &str = "RailSlug"; +/// One torpedo. Every bay type loads the same torpedo; the bay decides its +/// flight. +pub const ITEM_TORPEDO: &str = "Torpedo"; +/// Ore mined from a rock-kind asteroid. +pub const ITEM_STONE_ORE: &str = "StoneOre"; +/// Ore mined from a metal-kind asteroid. +pub const ITEM_IRON_ORE: &str = "IronOre"; +/// Ice mined from an ice-kind asteroid. +pub const ITEM_WATER_ICE: &str = "WaterIce"; +/// Ore mined from a carbon-kind asteroid. +pub const ITEM_CARBON_ORE: &str = "CarbonOre"; +/// Packed crew food. Generated sector ship holds draw it. +pub const ITEM_RATIONS: &str = "Rations"; +/// Reusable ship components. Generated sector ship holds draw them. +pub const ITEM_SALVAGED_PARTS: &str = "SalvagedParts"; + +/// Every item engine code names, with the category it must keep. Content lint +/// and the merge refuse a catalog without one of these ids and a definition +/// that files one under another category: the repair, the weapon reloads, +/// the mining yield and the sector hold draws would otherwise spend or make +/// something else. +pub const ITEM_ROLES: [(&str, ItemCategoryType); 10] = [ + (ITEM_HULL_PLATE, ItemCategoryType::Repair), + (ITEM_PDC_ROUND, ItemCategoryType::Ammo), + (ITEM_RAIL_SLUG, ItemCategoryType::Ammo), + (ITEM_TORPEDO, ItemCategoryType::Ammo), + (ITEM_STONE_ORE, ItemCategoryType::Raw), + (ITEM_IRON_ORE, ItemCategoryType::Raw), + (ITEM_WATER_ICE, ItemCategoryType::Raw), + (ITEM_CARBON_ORE, ItemCategoryType::Raw), + (ITEM_RATIONS, ItemCategoryType::Food), + (ITEM_SALVAGED_PARTS, ItemCategoryType::Parts), +]; + +/// One authored item: what it is called, what it is for, what it weighs and +/// what a trader pays for it. Every field is required. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +pub struct ItemDesign { + /// The catalog id stock, cargo and saves name it by. + pub id: ItemDesignId, + /// The display name, as the Inventory pane and a canister's tag show it. + pub name: String, + /// One or two sentences the Inventory pane shows for the selected item. + pub about: String, + /// The category the item is filed under; picks its icon and color. + pub category: ItemCategoryType, + /// Fixed mass of one item in grams, above zero. Grams keep capacity and + /// conservation arithmetic exact for items lighter than a kilogram. + pub mass_g: u32, + /// Credits a trader asks for one item: what a Buy pays. + pub ask_cr: u32, + /// Credits a trader bids for one item: what a Sell earns. At most + /// [`ask_cr`](Self::ask_cr), so buying and selling back never gains + /// credits. + pub bid_cr: u32, +} + +impl ItemDesign { /// Mass of `count` of this item in grams. Wide so a count read from /// content or typed at the Command shell cannot overflow before a /// capacity check refuses it. - pub fn stack_mass_g(self, count: u32) -> u64 { - u64::from(count) * u64::from(self.mass_g()) + pub fn stack_mass_g(&self, count: u32) -> u64 { + u64::from(count) * u64::from(self.mass_g) } +} - /// The category the item is filed under. - pub fn category(self) -> ItemCategoryType { - match self { - Self::HullPlate => ItemCategoryType::Repair, - Self::PdcRound | Self::RailSlug | Self::Torpedo => ItemCategoryType::Ammo, - Self::StoneOre | Self::IronOre | Self::WaterIce | Self::CarbonOre => { - ItemCategoryType::Raw - } - Self::Rations => ItemCategoryType::Food, - Self::SalvagedParts => ItemCategoryType::Parts, - } +/// The effective item catalog: every [`ItemDesign`] the enabled mods +/// registered, after the merge applied its overlay rule. Inserted by the mod +/// merge (`nova_assets`'s `register_bundles`) and read by every item flow. +#[derive(Resource, Clone, Debug, Default, PartialEq, Eq)] +pub struct GameItems(BTreeMap); + +impl GameItems { + /// The catalog of `designs`; a later design with an id replaces an earlier + /// one, as a dependent mod's overlay does. + pub fn new(designs: impl IntoIterator) -> Self { + Self( + designs + .into_iter() + .map(|design| (design.id.clone(), design)) + .collect(), + ) } - /// The item's display name, as the Inventory pane and a canister's tag - /// show it. - pub fn label(self) -> &'static str { - match self { - Self::HullPlate => "Hull plate", - Self::PdcRound => "PDC round", - Self::RailSlug => "Rail slug", - Self::Torpedo => "Torpedo", - Self::StoneOre => "Stone ore", - Self::IronOre => "Iron ore", - Self::WaterIce => "Water ice", - Self::CarbonOre => "Carbon ore", - Self::Rations => "Rations", - Self::SalvagedParts => "Salvaged parts", - } + /// The item with this id, or `None` if nothing authored it. + pub fn get(&self, id: &ItemDesignId) -> Option<&ItemDesign> { + self.0.get(id) + } + + /// The item with this id, for an id content lint or a save check already + /// resolved. + /// + /// # Panics + /// + /// When no item has the id: the caller let an unchecked id into a hold, + /// a canister or a reload. + pub fn design(&self, id: &ItemDesignId) -> &ItemDesign { + self.get(id) + .unwrap_or_else(|| panic!("item '{id}' is not in the loaded item catalog")) + } + + /// Every item, in id order. + pub fn iter(&self) -> impl Iterator { + self.0.values() } } @@ -165,6 +206,7 @@ pub fn kg_text(grams: u64) -> String { /// What an item is for. There is no fuel category: thrust never consumes /// stock. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum ItemCategoryType { /// Mined or salvaged bulk material. Raw, @@ -178,18 +220,19 @@ pub enum ItemCategoryType { Parts, } -/// The items an authored ship carries at spawn, as a count per item type. -/// RON writes a bare map: `{HullPlate: 12}`, or `{}` for none. +/// The items an authored ship carries at spawn, as a count per item id. +/// RON writes a map with quoted keys: `{"HullPlate": 12}`, or `{}` for none. /// /// Holds no capacity: the spawn reads that from the resolved design and /// consumes this component into the ship's [`ShipInventory`]. Content lint -/// refuses stock heavier than the design's hold before the spawn does. +/// refuses an unknown item and stock heavier than the design's hold before +/// the spawn does. /// /// The `Default` holds nothing, as `SpaceshipConfig`'s `Default` needs; RON /// still requires the field. #[derive(Component, Clone, Debug, Default, PartialEq, Eq, Reflect)] pub struct ShipInventoryStock { - stacks: BTreeMap, + stacks: BTreeMap, } impl ShipInventoryStock { @@ -199,7 +242,7 @@ impl ShipInventoryStock { /// /// On a zero quantity or a repeated item: the same faults the authored RON /// refuses, so a builder that writes one has a bug. - pub fn new(stacks: impl IntoIterator) -> Self { + pub fn new(stacks: impl IntoIterator) -> Self { match Self::checked(stacks) { Ok(stock) => stock, Err(fault) => panic!("{fault}"), @@ -207,36 +250,41 @@ impl ShipInventoryStock { } /// The one validation both [`new`](Self::new) and the RON parse run. - fn checked(stacks: impl IntoIterator) -> Result { + fn checked(stacks: impl IntoIterator) -> Result { let mut held = BTreeMap::new(); for (item, count) in stacks { if count == 0 { return Err(format!( - "ShipInventoryStock stack of {item:?} has quantity 0" + "ShipInventoryStock stack of '{item}' has quantity 0" )); } - if held.insert(item, count).is_some() { - return Err(format!("ShipInventoryStock lists {item:?} twice")); + if held.contains_key(&item) { + return Err(format!("ShipInventoryStock lists '{item}' twice")); } + held.insert(item, count); } Ok(Self { stacks: held }) } - /// Every stack, in [`ItemType`] order. Each count is above zero. - pub fn stacks(&self) -> impl Iterator + '_ { - self.stacks.iter().map(|(item, count)| (*item, *count)) + /// Every stack, in id order. Each count is above zero. + pub fn stacks(&self) -> impl Iterator + '_ { + self.stacks.iter().map(|(item, count)| (item, *count)) } /// Mass of every stack in grams. Wide because authored stock has no /// capacity bound until the spawn applies one. - pub fn mass_g(&self) -> u64 { + /// + /// # Panics + /// + /// When `items` lacks a stacked id; content lint refuses that stock first. + pub fn mass_g(&self, items: &GameItems) -> u64 { self.stacks() - .map(|(item, count)| item.stack_mass_g(count)) + .map(|(item, count)| items.design(item).stack_mass_g(count)) .sum() } } -/// The items one ship carries, as a count per item type, and the mass it has +/// The items one ship carries, as a count per item id, and the mass it has /// room for across all stacks. Required by every /// [`SpaceshipRootMarker`](crate::markers::SpaceshipRootMarker). /// @@ -247,7 +295,7 @@ impl ShipInventoryStock { #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] pub struct ShipInventory { capacity_g: u32, - stacks: BTreeMap, + stacks: BTreeMap, } impl ShipInventory { @@ -255,12 +303,17 @@ impl ShipInventory { /// /// # Panics /// - /// On a zero quantity, a repeated item, or stacks heavier than - /// `capacity_g`. Content lint refuses authored stock past a design's - /// hold, so a spawn that reaches this panic loaded unlinted content. - pub fn new(capacity_g: u32, stacks: impl IntoIterator) -> Self { + /// On a zero quantity, a repeated item, an item `items` lacks, or stacks + /// heavier than `capacity_g`. Content lint refuses authored stock past a + /// design's hold, so a spawn that reaches this panic loaded unlinted + /// content. + pub fn new( + items: &GameItems, + capacity_g: u32, + stacks: impl IntoIterator, + ) -> Self { let stock = ShipInventoryStock::new(stacks); - let mass_g = stock.mass_g(); + let mass_g = stock.mass_g(items); assert!( mass_g <= u64::from(capacity_g), "ShipInventory holds {} but has capacity {}", @@ -279,27 +332,26 @@ impl ShipInventory { } /// How many grams the ship carries across all stacks. - pub fn used_g(&self) -> u32 { + pub fn used_g(&self, items: &GameItems) -> u32 { // The capacity bounds the sum, so each product and the sum fit. self.stacks() - .map(|(item, count)| item.mass_g() * count) + .map(|(item, count)| items.design(item).mass_g * count) .sum() } /// How many more grams the ship has room for. - pub fn free_g(&self) -> u32 { - self.capacity_g - self.used_g() + pub fn free_g(&self, items: &GameItems) -> u32 { + self.capacity_g - self.used_g(items) } /// How many of `item` the ship carries; zero when it has no stack. - pub fn count(&self, item: ItemType) -> u32 { - self.stacks.get(&item).copied().unwrap_or(0) + pub fn count(&self, item: &ItemDesignId) -> u32 { + self.stacks.get(item).copied().unwrap_or(0) } - /// Every stack the ship carries, in [`ItemType`] order. Each count is above - /// zero. - pub fn stacks(&self) -> impl Iterator + '_ { - self.stacks.iter().map(|(item, count)| (*item, *count)) + /// Every stack the ship carries, in id order. Each count is above zero. + pub fn stacks(&self) -> impl Iterator + '_ { + self.stacks.iter().map(|(item, count)| (item, *count)) } /// True when the ship carries no stack. @@ -313,15 +365,16 @@ impl ShipInventory { /// /// On `count == 0` or a mass past [`free_g`](Self::free_g): the caller /// has a bug and must plan the add first, as [`plan_item_transfer`] does. - pub fn add(&mut self, item: ItemType, count: u32) { - assert!(count > 0, "ShipInventory adds 0 of {item:?}"); - let free_g = self.free_g(); + pub fn add(&mut self, items: &GameItems, item: &ItemDesignId, count: u32) { + assert!(count > 0, "ShipInventory adds 0 of '{item}'"); + let free_g = self.free_g(items); assert!( - item.stack_mass_g(count) <= u64::from(free_g), - "ShipInventory adds {count} of {item:?} but has room for {}", + items.design(item).stack_mass_g(count) <= u64::from(free_g), + "ShipInventory adds {count} of '{item}' but has room for {}", kg_text(u64::from(free_g)), ); - self.stacks.insert(item, self.count(item) + count); + let held = self.count(item); + self.stacks.insert(item.clone(), held + count); } /// Remove `count` of `item`; delete the stack when it empties. @@ -330,17 +383,17 @@ impl ShipInventory { /// /// On `count == 0` or more than the ship carries: the caller has a bug and /// must check [`count`](Self::count) first. - pub fn remove(&mut self, item: ItemType, count: u32) { - assert!(count > 0, "ShipInventory removes 0 of {item:?}"); + pub fn remove(&mut self, item: &ItemDesignId, count: u32) { + assert!(count > 0, "ShipInventory removes 0 of '{item}'"); let held = self.count(item); assert!( count <= held, - "ShipInventory removes {count} of {item:?} but carries {held}" + "ShipInventory removes {count} of '{item}' but carries {held}" ); if count == held { - self.stacks.remove(&item); + self.stacks.remove(item); } else { - self.stacks.insert(item, held - count); + self.stacks.insert(item.clone(), held - count); } } } @@ -396,11 +449,12 @@ pub enum ItemTransferRefusalType { /// needs `partner_lootable`: the partner is neutralized or carries /// [`LootableShipMarker`]; a Give does not read it. Checks run in /// [`ItemTransferRefusalType`] order. `quantity` is `None` when the typed -/// text is not a whole number. +/// text is not a whole number. `item` resolves in `items`. pub fn plan_item_transfer( + items: &GameItems, transfer: ItemTransferType, partner_lootable: bool, - item: ItemType, + item: &ItemDesignId, quantity: Option, own: &ShipInventory, partner: &ShipInventory, @@ -422,8 +476,8 @@ pub fn plan_item_transfer( if quantity > held { return Err(ItemTransferRefusalType::Short { held }); } - let free_g = target.free_g(); - if item.stack_mass_g(quantity) > u64::from(free_g) { + let free_g = target.free_g(items); + if items.design(item).stack_mass_g(quantity) > u64::from(free_g) { return Err(ItemTransferRefusalType::NoRoom { free_g }); } Ok(quantity) @@ -482,17 +536,19 @@ pub enum ItemTradeRefusalType { /// /// `partner_trades` is true when the partner is neither neutralized nor /// carries [`LootableShipMarker`]. A Buy moves items from the partner to the -/// player at [`ItemType::ask_cr`] each; a Sell moves them from the player to -/// the partner at [`ItemType::bid_cr`] each. Checks run in -/// [`ItemTradeRefusalType`] order, and a refusal changes nothing. +/// player at [`ItemDesign::ask_cr`] each; a Sell moves them from the player to +/// the partner at [`ItemDesign::bid_cr`] each. Checks run in +/// [`ItemTradeRefusalType`] order, and a refusal changes nothing. `item` +/// resolves in `items`. #[expect( clippy::too_many_arguments, reason = "both ships' stock and credits, read as one atomic plan" )] pub fn plan_item_trade( + items: &GameItems, trade: ItemTradeType, partner_trades: bool, - item: ItemType, + item: &ItemDesignId, quantity: Option, own: &ShipInventory, own_cr: u32, @@ -506,16 +562,17 @@ pub fn plan_item_trade( if quantity == 0 { return Err(ItemTradeRefusalType::ZeroQuantity); } + let design = items.design(item); let (seller, buyer, buyer_cr, seller_cr, unit_cr) = match trade { - ItemTradeType::Buy => (partner, own, own_cr, partner_cr, item.ask_cr()), - ItemTradeType::Sell => (own, partner, partner_cr, own_cr, item.bid_cr()), + ItemTradeType::Buy => (partner, own, own_cr, partner_cr, design.ask_cr), + ItemTradeType::Sell => (own, partner, partner_cr, own_cr, design.bid_cr), }; let held = seller.count(item); if quantity > held { return Err(ItemTradeRefusalType::Short { held }); } - let free_g = buyer.free_g(); - if item.stack_mass_g(quantity) > u64::from(free_g) { + let free_g = buyer.free_g(items); + if design.stack_mass_g(quantity) > u64::from(free_g) { return Err(ItemTradeRefusalType::NoRoom { free_g }); } let price = u64::from(quantity) * u64::from(unit_cr); @@ -579,44 +636,43 @@ pub const CARGO_CANISTER_MAX_MASS_G: u32 = 200_000; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] pub struct CargoCanister { - stacks: BTreeMap, + stacks: BTreeMap, } impl CargoCanister { - /// Start a canister with one valid stack. - pub fn new(item: ItemType, count: u32) -> Self { + /// Start a canister with one valid stack of an item `items` holds. + pub fn new(items: &GameItems, item: &ItemDesignId, count: u32) -> Self { assert!( count > 0 - && u64::from(count) * u64::from(item.mass_g()) - <= u64::from(CARGO_CANISTER_MAX_MASS_G), + && items.design(item).stack_mass_g(count) <= u64::from(CARGO_CANISTER_MAX_MASS_G), "invalid canister stack" ); Self { - stacks: [(item, count)].into(), + stacks: [(item.clone(), count)].into(), } } - /// Every held stack in item order. - pub fn stacks(&self) -> impl Iterator + '_ { - self.stacks.iter().map(|(&item, &count)| (item, count)) + /// Every held stack in id order. + pub fn stacks(&self) -> impl Iterator + '_ { + self.stacks.iter().map(|(item, &count)| (item, count)) } /// Total mass in grams. - pub fn total_mass_g(&self) -> u32 { + pub fn total_mass_g(&self, items: &GameItems) -> u32 { self.stacks() - .map(|(item, count)| item.mass_g() * count) + .map(|(item, count)| items.design(item).mass_g * count) .sum() } /// Add a stack after the jettison rule has checked the resulting mass. - pub fn add(&mut self, item: ItemType, count: u32) { + pub fn add(&mut self, items: &GameItems, item: &ItemDesignId, count: u32) { assert!( count > 0 - && u64::from(self.total_mass_g()) + u64::from(count) * u64::from(item.mass_g()) + && u64::from(self.total_mass_g(items)) + items.design(item).stack_mass_g(count) <= u64::from(CARGO_CANISTER_MAX_MASS_G), "canister mass exceeded" ); - *self.stacks.entry(item).or_default() += count; + *self.stacks.entry(item.clone()).or_default() += count; } } @@ -687,7 +743,7 @@ pub enum ItemJettisonRefusalType { /// What the ship carries. held: u32, }, - /// One item is heavier than a canister holds. No current item is. + /// One item is heavier than a canister holds. Overweight, } @@ -713,10 +769,11 @@ pub struct ItemJettison { /// Checks run in [`ItemJettisonRefusalType`] order. `quantity` is `None` when /// the typed text is not a whole number. pub fn plan_item_jettison( + items: &GameItems, docked: bool, has_intake: bool, tail: Option<&CargoCanister>, - item: ItemType, + item: &ItemDesignId, quantity: Option, own: &ShipInventory, ) -> Result { @@ -734,19 +791,20 @@ pub fn plan_item_jettison( if quantity > held { return Err(ItemJettisonRefusalType::Short { held }); } - let per_canister = CARGO_CANISTER_MAX_MASS_G / item.mass_g(); + let mass_g = items.design(item).mass_g; + let per_canister = CARGO_CANISTER_MAX_MASS_G / mass_g; if per_canister == 0 { return Err(ItemJettisonRefusalType::Overweight); } let tail_room = tail.map_or(0, |tail| { - (CARGO_CANISTER_MAX_MASS_G - tail.total_mass_g()) / item.mass_g() + (CARGO_CANISTER_MAX_MASS_G - tail.total_mass_g(items)) / mass_g }); let merged = quantity.min(tail_room); let mut rest = quantity - merged; let mut canisters = Vec::new(); while rest > 0 { let count = rest.min(per_canister); - canisters.push(CargoCanister::new(item, count)); + canisters.push(CargoCanister::new(items, item, count)); rest -= count; } Ok(ItemJettison { @@ -845,7 +903,7 @@ impl<'de> serde::Deserialize<'de> for ShipInventoryStock { type Value = ShipInventoryStock; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("a map of item type to stack count") + formatter.write_str("a map of item id to stack count") } fn visit_map>( @@ -853,7 +911,7 @@ impl<'de> serde::Deserialize<'de> for ShipInventoryStock { mut map: A, ) -> Result { let mut stacks = Vec::new(); - while let Some(entry) = map.next_entry::()? { + while let Some(entry) = map.next_entry::()? { stacks.push(entry); } ShipInventoryStock::checked(stacks).map_err(serde::de::Error::custom) @@ -871,14 +929,16 @@ mod tests { #[test] fn kg_text_shows_grams_as_kilograms_without_trailing_zeros() { assert_eq!(kg_text(0), "0 kg"); - assert_eq!(kg_text(u64::from(ItemType::PdcRound.mass_g())), "0.2 kg"); - assert_eq!(kg_text(u64::from(ItemType::HullPlate.mass_g())), "10 kg"); + let items = crate::test_support::test_items(); + let mass_g = |id: &str| u64::from(items.design(&ItemDesignId::from(id)).mass_g); + assert_eq!(kg_text(mass_g(ITEM_PDC_ROUND)), "0.2 kg"); + assert_eq!(kg_text(mass_g(ITEM_HULL_PLATE)), "10 kg"); assert_eq!(kg_text(1_050), "1.05 kg"); assert_eq!(kg_text(3_520_001), "3520.001 kg"); // 6000 PDC rounds weigh exactly 1200 kg: no float drift in the sum. - let rounds = ShipInventory::new(1_200_000, [(ItemType::PdcRound, 6000)]); - assert_eq!(rounds.used_g(), 1_200_000); - assert_eq!(rounds.free_g(), 0); + let rounds = ShipInventory::new(&items, 1_200_000, [(ITEM_PDC_ROUND.into(), 6000)]); + assert_eq!(rounds.used_g(&items), 1_200_000); + assert_eq!(rounds.free_g(&items), 0); } #[test] @@ -945,15 +1005,18 @@ mod transfer_tests { #[test] fn item_transfer_plans_refuse_in_order_and_never_pass_capacity() { + let items = crate::test_support::test_items(); use ItemTransferRefusalType::*; use ItemTransferType::*; // 400 kg holds 40 plates of 10 kg. - let plates = |count: u32| ShipInventory::new(400_000, [(ItemType::HullPlate, count)]); + let plates = + |count: u32| ShipInventory::new(&items, 400_000, [(ITEM_HULL_PLATE.into(), count)]); let plan = |transfer, lootable, quantity, own: &ShipInventory, partner: &ShipInventory| { plan_item_transfer( + &items, transfer, lootable, - ItemType::HullPlate, + &ItemDesignId::from(ITEM_HULL_PLATE), quantity, own, partner, @@ -1000,20 +1063,22 @@ mod transfer_tests { // A planned move conserves the total and empties a drained stack. let (mut own, mut partner) = (own, partner); let moved = plan(Take, true, Some(8), &own, &partner).expect("planned"); - partner.remove(ItemType::HullPlate, moved); - own.add(ItemType::HullPlate, moved); - assert_eq!(own.count(ItemType::HullPlate), 20); - assert_eq!(own.used_g(), 200_000); - assert_eq!(own.free_g(), 200_000); + partner.remove(&ItemDesignId::from(ITEM_HULL_PLATE), moved); + own.add(&items, &ItemDesignId::from(ITEM_HULL_PLATE), moved); + assert_eq!(own.count(&ItemDesignId::from(ITEM_HULL_PLATE)), 20); + assert_eq!(own.used_g(&items), 200_000); + assert_eq!(own.free_g(&items), 200_000); assert!(partner.is_empty()); } #[test] fn item_trade_plans_refuse_in_order_and_conserve_items_and_credits() { + let items = crate::test_support::test_items(); use ItemTradeRefusalType::*; use ItemTradeType::*; // 400 kg of room; ore is 10 kg, ask 4 and bid 3 for stone. - let ore = |count: u32| ShipInventory::new(400_000, [(ItemType::StoneOre, count)]); + let ore = + |count: u32| ShipInventory::new(&items, 400_000, [(ITEM_STONE_ORE.into(), count)]); let plan = |trade, trades, quantity, @@ -1022,9 +1087,10 @@ mod transfer_tests { partner: &ShipInventory, partner_cr| { plan_item_trade( + &items, trade, trades, - ItemType::StoneOre, + &ItemDesignId::from(ITEM_STONE_ORE), quantity, own, own_cr, @@ -1049,7 +1115,7 @@ mod transfer_tests { }) ); // Exact funds and exact room are valid; both balances may reach zero. - let mut exact = ShipInventory::new(50_000, []); + let mut exact = ShipInventory::new(&items, 50_000, []); let mut seller = ore(5); let (mut buyer_cr, mut seller_cr) = (20, 0); assert_eq!( @@ -1064,7 +1130,7 @@ mod transfer_tests { Buy, true, Some(5), - &ShipInventory::new(49_999, []), + &ShipInventory::new(&items, 49_999, []), 20, &seller, 0 @@ -1075,13 +1141,14 @@ mod transfer_tests { plan(Buy, true, Some(5), &exact, 19, &seller, 0), Err(NoCredits { credits: 19 }) ); - seller.remove(ItemType::StoneOre, 5); - exact.add(ItemType::StoneOre, 5); + seller.remove(&ItemDesignId::from(ITEM_STONE_ORE), 5); + exact.add(&items, &ItemDesignId::from(ITEM_STONE_ORE), 5); buyer_cr -= 20; seller_cr += 20; - assert_eq!((buyer_cr, seller_cr, exact.free_g()), (0, 20, 0)); + assert_eq!((buyer_cr, seller_cr, exact.free_g(&items)), (0, 20, 0)); assert_eq!( - exact.count(ItemType::StoneOre) + seller.count(ItemType::StoneOre), + exact.count(&ItemDesignId::from(ITEM_STONE_ORE)) + + seller.count(&ItemDesignId::from(ITEM_STONE_ORE)), 5 ); assert_eq!( @@ -1093,7 +1160,7 @@ mod transfer_tests { Buy, true, Some(1), - &ShipInventory::new(400_000, []), + &ShipInventory::new(&items, 400_000, []), buyer_cr, &ore(1), seller_cr, @@ -1156,12 +1223,13 @@ mod transfer_tests { let (mut own, mut partner) = (own, partner); let (mut own_cr, mut partner_cr) = (100u32, 7u32); let trade = plan(Buy, true, Some(5), &own, own_cr, &partner, partner_cr).expect("planned"); - partner.remove(ItemType::StoneOre, trade.count); - own.add(ItemType::StoneOre, trade.count); + partner.remove(&ItemDesignId::from(ITEM_STONE_ORE), trade.count); + own.add(&items, &ItemDesignId::from(ITEM_STONE_ORE), trade.count); own_cr -= trade.price_cr; partner_cr += trade.price_cr; assert_eq!( - own.count(ItemType::StoneOre) + partner.count(ItemType::StoneOre), + own.count(&ItemDesignId::from(ITEM_STONE_ORE)) + + partner.count(&ItemDesignId::from(ITEM_STONE_ORE)), 40 ); assert_eq!((own_cr, partner_cr), (80, 27)); @@ -1190,15 +1258,17 @@ mod transfer_tests { #[test] fn item_jettison_plans_refuse_in_order() { + let items = crate::test_support::test_items(); use ItemJettisonRefusalType::*; - let own = ShipInventory::new(400_000, [(ItemType::HullPlate, 12)]); - let tail = CargoCanister::new(ItemType::HullPlate, 9); + let own = ShipInventory::new(&items, 400_000, [(ITEM_HULL_PLATE.into(), 12)]); + let tail = CargoCanister::new(&items, &ItemDesignId::from(ITEM_HULL_PLATE), 9); let plan = |docked, has_intake, tail: Option<&CargoCanister>, quantity| { plan_item_jettison( + &items, docked, has_intake, tail, - ItemType::HullPlate, + &ItemDesignId::from(ITEM_HULL_PLATE), quantity, &own, ) @@ -1215,60 +1285,78 @@ mod transfer_tests { #[test] fn item_jettison_fills_the_waiting_tail_then_splits_the_rest_by_whole_items() { + let items = crate::test_support::test_items(); let own = ShipInventory::new( + &items, 10_000_000, [ - (ItemType::HullPlate, 12), - (ItemType::PdcRound, 2_500), - (ItemType::RailSlug, 25), - (ItemType::Torpedo, 3), + (ITEM_HULL_PLATE.into(), 12), + (ITEM_PDC_ROUND.into(), 2_500), + (ITEM_RAIL_SLUG.into(), 25), + (ITEM_TORPEDO.into(), 3), ], ); let plan = |tail: Option<&CargoCanister>, item, quantity| { - plan_item_jettison(false, true, tail, item, Some(quantity), &own) - .expect("the ship holds the quantity") + plan_item_jettison( + &items, + false, + true, + tail, + &ItemDesignId::from(item), + Some(quantity), + &own, + ) + .expect("the ship holds the quantity") }; let split = |jettison: &ItemJettison| { jettison .canisters .iter() - .map(|canister| canister.stacks().collect::>()) + .map(|canister| { + canister + .stacks() + .map(|(item, count)| (item.as_str().to_string(), count)) + .collect::>() + }) .collect::>() }; // One 150 kg torpedo per canister; 10 slugs and 1000 rounds fill one. - let torpedoes = plan(None, ItemType::Torpedo, 3); + let torpedoes = plan(None, ITEM_TORPEDO, 3); assert_eq!((torpedoes.count, torpedoes.merged), (3, 0)); - assert_eq!(split(&torpedoes), vec![vec![(ItemType::Torpedo, 1)]; 3]); - let slugs = plan(None, ItemType::RailSlug, 25); + assert_eq!( + split(&torpedoes), + vec![vec![(ITEM_TORPEDO.to_string(), 1)]; 3] + ); + let slugs = plan(None, ITEM_RAIL_SLUG, 25); assert_eq!( split(&slugs), [ - vec![(ItemType::RailSlug, 10)], - vec![(ItemType::RailSlug, 10)], - vec![(ItemType::RailSlug, 5)], + vec![(ITEM_RAIL_SLUG.to_string(), 10)], + vec![(ITEM_RAIL_SLUG.to_string(), 10)], + vec![(ITEM_RAIL_SLUG.to_string(), 5)], ] ); - let rounds = plan(None, ItemType::PdcRound, 2_500); + let rounds = plan(None, ITEM_PDC_ROUND, 2_500); assert_eq!( split(&rounds), [ - vec![(ItemType::PdcRound, 1_000)], - vec![(ItemType::PdcRound, 1_000)], - vec![(ItemType::PdcRound, 500)], + vec![(ITEM_PDC_ROUND.to_string(), 1_000)], + vec![(ITEM_PDC_ROUND.to_string(), 1_000)], + vec![(ITEM_PDC_ROUND.to_string(), 500)], ] ); // A waiting 90 kg tail has 110 kg of room: 5 slugs merge ... - let tail = CargoCanister::new(ItemType::HullPlate, 9); - let into_tail = plan(Some(&tail), ItemType::RailSlug, 7); + let tail = CargoCanister::new(&items, &ItemDesignId::from(ITEM_HULL_PLATE), 9); + let into_tail = plan(Some(&tail), ITEM_RAIL_SLUG, 7); assert_eq!((into_tail.count, into_tail.merged), (7, 5)); - assert_eq!(split(&into_tail), [vec![(ItemType::RailSlug, 2)]]); + assert_eq!(split(&into_tail), [vec![(ITEM_RAIL_SLUG.to_string(), 2)]]); // ... and no torpedo, which starts a new canister. - let past_tail = plan(Some(&tail), ItemType::Torpedo, 1); + let past_tail = plan(Some(&tail), ITEM_TORPEDO, 1); assert_eq!(past_tail.merged, 0); - assert_eq!(split(&past_tail), [vec![(ItemType::Torpedo, 1)]]); - let fits = plan(Some(&tail), ItemType::HullPlate, 11); + assert_eq!(split(&past_tail), [vec![(ITEM_TORPEDO.to_string(), 1)]]); + let fits = plan(Some(&tail), ITEM_HULL_PLATE, 11); assert_eq!((fits.merged, fits.canisters.len()), (11, 0)); // Every planned count is conserved across the tail and the canisters. @@ -1290,26 +1378,35 @@ mod serde_tests { #[test] fn authored_stock_rejects_a_zero_stack_and_a_repeated_item() { - let ok: ShipInventoryStock = ron::from_str("{HullPlate: 12}").expect("valid stock parses"); - assert_eq!(ok.stacks().collect::>(), [(ItemType::HullPlate, 12)]); - assert_eq!(ok.mass_g(), 120_000); + let items = crate::test_support::test_items(); + let ok: ShipInventoryStock = + ron::from_str(r#"{"HullPlate": 12}"#).expect("valid stock parses"); + assert_eq!( + ok.stacks().collect::>(), + [(&ItemDesignId::from(ITEM_HULL_PLATE), 12)] + ); + assert_eq!(ok.mass_g(&items), 120_000); let round_trip: ShipInventoryStock = ron::from_str(&ron::to_string(&ok).expect("serializes")).expect("parses back"); assert_eq!(round_trip, ok); - let empty: ShipInventoryStock = ron::from_str("{}").expect("empty stock parses"); - assert_eq!(empty.mass_g(), 0); + let empty: ShipInventoryStock = ron::from_str(r#"{}"#).expect("empty stock parses"); + assert_eq!(empty.mass_g(&items), 0); - let zero = ron::from_str::("{HullPlate: 0}") + let zero = ron::from_str::(r#"{"HullPlate": 0}"#) .expect_err("a zero-quantity stack must fail"); assert!(zero.to_string().contains("quantity 0"), "{zero}"); - let dup = ron::from_str::("{HullPlate: 1, HullPlate: 2}") + let dup = ron::from_str::(r#"{"HullPlate": 1, "HullPlate": 2}"#) .expect_err("a repeated item must fail"); assert!(dup.to_string().contains("twice"), "{dup}"); + // Ids are strings: the unquoted spelling of the closed enum is gone. + ron::from_str::("{HullPlate: 1}") + .expect_err("an unquoted item id must fail"); + // The largest count still weighs without overflow, for lint to refuse. let huge: ShipInventoryStock = - ron::from_str("{HullPlate: 4294967295}").expect("a huge count parses"); - assert_eq!(huge.mass_g(), 42_949_672_950_000); + ron::from_str(r#"{"HullPlate": 4294967295}"#).expect("a huge count parses"); + assert_eq!(huge.mass_g(&items), 42_949_672_950_000); } } diff --git a/crates/nova_gameplay/src/test_support.rs b/crates/nova_gameplay/src/test_support.rs index 6dba94f94..e14d0618f 100644 --- a/crates/nova_gameplay/src/test_support.rs +++ b/crates/nova_gameplay/src/test_support.rs @@ -70,3 +70,39 @@ pub fn settle(app: &mut App) { app.update(); } } + +/// An item catalog with the base game's ten items and their base values, for +/// tests that move stock without merging content. Mirrors the base content +/// builder (`nova_authoring`'s `base_content::items`); a test that reads a +/// mass or a price reads it through this catalog, never as a literal. +pub fn test_items() -> crate::inventory::GameItems { + use crate::inventory::{ItemCategoryType::*, *}; + let item = |id: &str, name: &str, category, mass_g, ask_cr, bid_cr| ItemDesign { + id: ItemDesignId::from(id), + name: name.to_string(), + about: format!("{name} for tests."), + category, + mass_g, + ask_cr, + bid_cr, + }; + GameItems::new([ + item(ITEM_HULL_PLATE, "Hull plate", Repair, 10_000, 40, 30), + item(ITEM_PDC_ROUND, "PDC round", Ammo, 200, 4, 3), + item(ITEM_RAIL_SLUG, "Rail slug", Ammo, 20_000, 40, 30), + item(ITEM_TORPEDO, "Torpedo", Ammo, 150_000, 400, 300), + item(ITEM_STONE_ORE, "Stone ore", Raw, 10_000, 4, 3), + item(ITEM_IRON_ORE, "Iron ore", Raw, 10_000, 16, 12), + item(ITEM_WATER_ICE, "Water ice", Raw, 10_000, 12, 9), + item(ITEM_CARBON_ORE, "Carbon ore", Raw, 10_000, 8, 6), + item(ITEM_RATIONS, "Rations", Food, 2_000, 8, 6), + item( + ITEM_SALVAGED_PARTS, + "Salvaged parts", + Parts, + 25_000, + 120, + 90, + ), + ]) +} diff --git a/crates/nova_hud/src/ammo_readout.rs b/crates/nova_hud/src/ammo_readout.rs index 28eb31a7d..40044029f 100644 --- a/crates/nova_hud/src/ammo_readout.rs +++ b/crates/nova_hud/src/ammo_readout.rs @@ -840,7 +840,7 @@ fn drive_ammo_readouts( .unwrap_or_else(|_| { panic!("weapon section {name} reloads from a parent with no ShipInventory") }) - .count(reload.item); + .count(&reload.item); (reload, reserve) }) .filter(|(reload, reserve)| reload.is_reloading(ammo, *reserve)); @@ -1022,21 +1022,24 @@ impl Plugin for AmmoReadoutPlugin { #[cfg(test)] mod tests { use bevy::ecs::system::RunSystemOnce; + use nova_gameplay::test_support::test_items; use super::*; /// The player ship, carrying a reserve for every weapon's reload. fn spawn_player(world: &mut World) -> Entity { + let items = test_items(); world .spawn(( SpaceshipRootMarker, PlayerSpaceshipMarker, ShipInventory::new( + &items, 10_000_000, [ - (ItemType::PdcRound, 1000), - (ItemType::RailSlug, 10), - (ItemType::Torpedo, 10), + (ITEM_PDC_ROUND.into(), 1000), + (ITEM_RAIL_SLUG.into(), 10), + (ITEM_TORPEDO.into(), 10), ], ), )) @@ -1674,7 +1677,7 @@ mod tests { .count() } - fn reload_at(item: ItemType, delay: f32, amount: u32, progress: f32) -> SectionReload { + fn reload_at(item: ItemDesignId, delay: f32, amount: u32, progress: f32) -> SectionReload { let mut reload = SectionReload::from_config(SectionReloadConfig { delay, amount }, item); reload.elapsed = delay * progress; reload @@ -1695,7 +1698,7 @@ mod tests { .rounds = 0; world .entity_mut(turret) - .insert(reload_at(ItemType::PdcRound, 3.0, 200, 0.5)); + .insert(reload_at(ITEM_PDC_ROUND.into(), 3.0, 200, 0.5)); world.run_system_once(sync_ammo_readouts).unwrap(); world.run_system_once(drive_ammo_readouts).unwrap(); @@ -1708,9 +1711,12 @@ mod tests { // A short reserve previews only what it can load; an empty one // previews nothing. - world - .entity_mut(player) - .insert(ShipInventory::new(10_000_000, [(ItemType::PdcRound, 50)])); + let items = test_items(); + world.entity_mut(player).insert(ShipInventory::new( + &items, + 10_000_000, + [(ITEM_PDC_ROUND.into(), 50)], + )); world.run_system_once(drive_ammo_readouts).unwrap(); assert_eq!( reload_pip_count(&mut world, turret), @@ -1718,7 +1724,7 @@ mod tests { ); world .entity_mut(player) - .insert(ShipInventory::new(10_000_000, [])); + .insert(ShipInventory::new(&items, 10_000_000, [])); world.run_system_once(drive_ammo_readouts).unwrap(); assert_eq!(reload_pip_count(&mut world, turret), 0); @@ -1742,7 +1748,7 @@ mod tests { .rounds = 1; world .entity_mut(torpedo) - .insert(reload_at(ItemType::Torpedo, 10.0, 1, 0.5)); + .insert(reload_at(ITEM_TORPEDO.into(), 10.0, 1, 0.5)); world.run_system_once(sync_ammo_readouts).unwrap(); world.run_system_once(drive_ammo_readouts).unwrap(); @@ -1821,7 +1827,7 @@ mod tests { .rounds = 1; world .entity_mut(torpedo) - .insert(reload_at(ItemType::Torpedo, 4.0, 1, 0.5)); + .insert(reload_at(ITEM_TORPEDO.into(), 4.0, 1, 0.5)); world.run_system_once(sync_ammo_readouts).unwrap(); world.run_system_once(drive_ammo_readouts).unwrap(); let reloading = lit_pip_color(&mut world, torpedo).expect("a lit pip"); @@ -1848,7 +1854,7 @@ mod tests { let turret = spawn_turret(&mut world, player, Some(SectionAmmo::new(8))); world .entity_mut(turret) - .insert(reload_at(ItemType::PdcRound, 2.0, 8, 0.0)); + .insert(reload_at(ITEM_PDC_ROUND.into(), 2.0, 8, 0.0)); world.run_system_once(sync_ammo_readouts).unwrap(); world.run_system_once(drive_ammo_readouts).unwrap(); @@ -1910,7 +1916,7 @@ mod tests { .rounds = 0; world .entity_mut(lance) - .insert(reload_at(ItemType::RailSlug, 12.0, 1, 0.25)); + .insert(reload_at(ITEM_RAIL_SLUG.into(), 12.0, 1, 0.25)); world.run_system_once(sync_ammo_readouts).unwrap(); world.run_system_once(drive_ammo_readouts).unwrap(); @@ -1922,7 +1928,7 @@ mod tests { world .entity_mut(lance) - .insert(reload_at(ItemType::RailSlug, 12.0, 1, 0.75)); + .insert(reload_at(ITEM_RAIL_SLUG.into(), 12.0, 1, 0.75)); world.run_system_once(drive_ammo_readouts).unwrap(); assert_eq!( fill_percent(&mut world, lance), @@ -1978,7 +1984,7 @@ mod tests { .rounds = 0; world .entity_mut(turret) - .insert(reload_at(ItemType::PdcRound, 3.0, 200, 0.5)); + .insert(reload_at(ITEM_PDC_ROUND.into(), 3.0, 200, 0.5)); world.run_system_once(sync_ammo_readouts).unwrap(); world.run_system_once(drive_ammo_readouts).unwrap(); diff --git a/crates/nova_hud/src/cargo_canister_chips.rs b/crates/nova_hud/src/cargo_canister_chips.rs index bef771d97..2cf6d5ea3 100644 --- a/crates/nova_hud/src/cargo_canister_chips.rs +++ b/crates/nova_hud/src/cargo_canister_chips.rs @@ -75,13 +75,13 @@ struct CargoCanisterChipMassMarker; /// What a canister chip reads: the item line ("4 Hull plate" or "Mixed cargo" /// once it holds more than one stack) and its total mass. -fn cargo_canister_chip_text(canister: &CargoCanister) -> (String, String) { +fn cargo_canister_chip_text(items: &GameItems, canister: &CargoCanister) -> (String, String) { let mut stacks = canister.stacks(); let label = match (stacks.next(), stacks.next()) { - (Some((item, count)), None) => format!("{count} {}", item.label()), + (Some((item, count)), None) => format!("{count} {}", items.design(item).name), _ => "Mixed cargo".to_string(), }; - (label, kg_text(u64::from(canister.total_mass_g()))) + (label, kg_text(u64::from(canister.total_mass_g(items)))) } /// UI bundle for one canister's chip layer, spawned hidden: the range pass @@ -163,6 +163,7 @@ fn update_cargo_canister_chips( q_lines: Query<&ChildOf>, q_canisters: Query<(&CargoCanister, &GlobalTransform)>, q_player: Query<&GlobalTransform, With>, + items: Res, ) { let player = q_player.iter().next().map(GlobalTransform::translation); let range = CARGO_TAG_RANGE.to_engine(); @@ -191,7 +192,7 @@ fn update_cargo_canister_chips( let Ok((canister, _)) = q_canisters.get(**target) else { continue; }; - let (label, mass) = cargo_canister_chip_text(canister); + let (label, mass) = cargo_canister_chip_text(&items, canister); let next = if is_mass { mass } else { label }; if **text != next { **text = next; @@ -201,7 +202,8 @@ fn update_cargo_canister_chips( /// Draws one content tag per [`CargoCanister`] near the player ship, reading /// its stacks and mass (Chrome tier). Adds the spawn and despawn observers and -/// runs the range and label pass in Update within [`super::NovaHudSystems`]. +/// runs the range and label pass in Update within [`super::NovaHudSystems`], +/// once the mod merge has published [`GameItems`]. #[derive(Default)] pub struct CargoCanisterChipsHudPlugin; @@ -211,9 +213,73 @@ impl Plugin for CargoCanisterChipsHudPlugin { app.add_observer(setup_cargo_canister_chip); app.add_observer(despawn_anchored_chips::); + // NovaHudSystems runs from the first boot frame; the catalog lands + // only at the merge, and never on a refused base game. app.add_systems( Update, - update_cargo_canister_chips.in_set(super::NovaHudSystems), + update_cargo_canister_chips + .run_if(resource_exists::) + .in_set(super::NovaHudSystems), ); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Before the mod merge publishes `GameItems`, Update skips the pass and + /// the app does not panic; once the catalog lands, the label line reads + /// the canister's authored item name. + #[test] + fn canister_chips_wait_for_the_merged_item_catalog() { + let fixture = ItemDesign { + id: ItemDesignId::from("fixture_core"), + name: "Fixture core".to_string(), + about: "A catalog-only item for tests.".to_string(), + category: ItemCategoryType::Parts, + mass_g: 5_000, + ask_cr: 60, + bid_cr: 45, + }; + let catalog = GameItems::new( + nova_gameplay::test_support::test_items() + .iter() + .cloned() + .chain([fixture]), + ); + + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_plugins(CargoCanisterChipsHudPlugin); + + app.world_mut().spawn(( + CargoCanister::new(&catalog, &ItemDesignId::from("fixture_core"), 2), + GlobalTransform::default(), + )); + + let label_of = |app: &mut App, want_mass: bool| -> Entity { + app.world_mut() + .query_filtered::<(Entity, Has), With>() + .iter(app.world()) + .find_map(|(entity, is_mass)| (is_mass == want_mass).then_some(entity)) + .expect("chip label exists") + }; + let label = label_of(&mut app, false); + let mass = label_of(&mut app, true); + let initial = app.world().get::(label).unwrap().0.clone(); + + app.update(); + app.update(); + assert_eq!( + app.world().get::(label).unwrap().0, + initial, + "without GameItems the update pass must not touch the label" + ); + + app.insert_resource(catalog); + app.update(); + assert_eq!(app.world().get::(label).unwrap().0, "2 Fixture core"); + assert_eq!(app.world().get::(mass).unwrap().0, kg_text(10_000)); + } +} diff --git a/crates/nova_hud/src/pickup_sight.rs b/crates/nova_hud/src/pickup_sight.rs index c008d2759..c120c1b15 100644 --- a/crates/nova_hud/src/pickup_sight.rs +++ b/crates/nova_hud/src/pickup_sight.rs @@ -313,7 +313,10 @@ impl Plugin for PickupSightPlugin { #[cfg(test)] mod tests { use avian3d::prelude::*; - use nova_gameplay::{asset_ref::AssetRef, test_support::unfinished_integrity_physics_app}; + use nova_gameplay::{ + asset_ref::AssetRef, + test_support::{test_items, unfinished_integrity_physics_app}, + }; use super::*; @@ -388,7 +391,7 @@ mod tests { app.world_mut() .spawn(( Name::new("canister"), - CargoCanister::new(ItemType::HullPlate, 1), + CargoCanister::new(&test_items(), &ITEM_HULL_PLATE.into(), 1), health, Transform::from_translation(at), GlobalTransform::from_translation(at), @@ -790,6 +793,7 @@ mod tests { let mut app = unfinished_integrity_physics_app(); app.init_asset::(); app.init_resource::(); + app.insert_resource(test_items()); app.add_plugins(( SectionAnimationPlugin, CargoIntakeSectionPlugin { render: false }, @@ -804,7 +808,7 @@ mod tests { RigidBody::Dynamic, Transform::default(), TravelLock(None), - ShipInventory::new(400_000, []), + ShipInventory::new(&test_items(), 400_000, []), )) .id(); app.world_mut().spawn(( @@ -854,7 +858,7 @@ mod tests { let canister = app .world_mut() .spawn(cargo_canister( - CargoCanister::new(ItemType::HullPlate, 4), + CargoCanister::new(&test_items(), &ITEM_HULL_PLATE.into(), 4), Transform::from_xyz(0.0, 0.0, -2.85), Vec3::ZERO, AssetRef::from("canister.glb#Scene0"), @@ -891,7 +895,7 @@ mod tests { app.world() .get::(player) .unwrap() - .count(ItemType::HullPlate), + .count(&ITEM_HULL_PLATE.into()), 4 ); assert_eq!(takes.load(std::sync::atomic::Ordering::Relaxed), 1); diff --git a/crates/nova_hud/src/situation.rs b/crates/nova_hud/src/situation.rs index d91bd8ed6..3ea408df3 100644 --- a/crates/nova_hud/src/situation.rs +++ b/crates/nova_hud/src/situation.rs @@ -119,7 +119,7 @@ pub fn sense_hud_situations( let reserve = q_inventory.get(ship).unwrap_or_else(|_| { panic!("weapon section {name} reloads from a ship with no ShipInventory") }); - reload.is_reloading(ammo, reserve.count(reload.item)) + reload.is_reloading(ammo, reserve.count(&reload.item)) }); } HudSituations { @@ -157,6 +157,8 @@ fn maneuver_chip(autopilot: &Autopilot) -> Option { #[cfg(test)] mod tests { + use nova_gameplay::test_support::test_items; + use super::*; fn sense_app() -> App { @@ -261,11 +263,12 @@ mod tests { #[test] fn reload_reads_the_players_incomplete_magazine_with_reserve() { let mut app = sense_app(); + let items = test_items(); let ship = app .world_mut() .spawn(( PlayerSpaceshipMarker, - ShipInventory::new(10_000, [(ItemType::PdcRound, 4)]), + ShipInventory::new(&items, 10_000, [(ITEM_PDC_ROUND.into(), 4)]), )) .id(); let section = app @@ -278,7 +281,7 @@ mod tests { delay: 3.0, amount: 4, }, - ItemType::PdcRound, + ITEM_PDC_ROUND.into(), ), ChildOf(ship), )) @@ -299,7 +302,7 @@ mod tests { app.world_mut() .entity_mut(ship) - .insert(ShipInventory::new(10_000, [])); + .insert(ShipInventory::new(&items, 10_000, [])); app.update(); assert!( !situations(&app).reloading, diff --git a/crates/nova_interface/src/inventory/app.rs b/crates/nova_interface/src/inventory/app.rs index 03d756446..fce30d640 100644 --- a/crates/nova_interface/src/inventory/app.rs +++ b/crates/nova_interface/src/inventory/app.rs @@ -116,10 +116,10 @@ pub(crate) struct InventoryTakeCreditsButton; pub(crate) struct InventoryColumnPanel(pub(crate) InventorySideType); /// A drawn stack. A click selects it for the inspector. -#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Component, Clone, PartialEq, Eq, Debug)] pub(crate) struct InventoryRow { pub(crate) side: InventorySideType, - pub(crate) item: ItemType, + pub(crate) item: ItemDesignId, } /// A part of the inspector that shows with the selection or without it. @@ -210,28 +210,12 @@ pub(crate) struct InventoryAboutBox; #[derive(Clone, PartialEq, Eq, Debug)] struct ColumnDraw { /// Every stack the side carries, or `None` when there is no partner. - stacks: Option>, + stacks: Option>, /// The filter the rows apply; `None` as well when there are no stacks, so /// a filter click does not redraw an absent partner. filter: Option, } -/// A one-line "what it is" for the inspector. -fn item_about(item: ItemType) -> &'static str { - match item { - ItemType::HullPlate => "Structural plating for hull sections.", - ItemType::PdcRound => "Point-defense round. Kinetic and Pierce mounts reload from it.", - ItemType::RailSlug => "Railgun slug. Railgun mounts reload from it.", - ItemType::Torpedo => "Torpedo. Every torpedo bay reloads from it.", - ItemType::StoneOre => "Silicate rock, mined from rock asteroids.", - ItemType::IronOre => "Metal-rich ore, mined from metal asteroids.", - ItemType::WaterIce => "Frozen volatiles, mined from ice asteroids.", - ItemType::CarbonOre => "Carbon-rich ore, mined from carbon asteroids.", - ItemType::Rations => "Packed crew food. Traders buy and sell it.", - ItemType::SalvagedParts => "Reusable ship components. Traders buy and sell them.", - } -} - /// The filter and inspector word for a category. fn category_label(category: ItemCategoryType) -> &'static str { match category { @@ -881,15 +865,17 @@ fn draft_form(form: &mut ChildSpawnerCommands) { fn inventory_row( list: &mut ChildSpawnerCommands, side: InventorySideType, - (item, count): (ItemType, u32), + (item, count): (ItemDesignId, u32), selected: bool, icons: &InterfaceIcons, + items: &GameItems, ) { - let category = item.category(); + let design = items.design(&item); + let category = design.category; let tone = category_color(category); let (fill, border) = row_alphas(selected); list.spawn(( - Name::new(format!("InventoryRow{side:?}{item:?}")), + Name::new(format!("InventoryRow{side:?}{item}")), Button, InventoryRow { side, item }, Node { @@ -912,7 +898,7 @@ fn inventory_row( .with_children(|row| { row.spawn(icon_node(icons.category(category), tone, 18.0)); row.spawn(( - themed_label(item.label(), 13.0, UiColor::Body), + themed_label(&design.name, 13.0, UiColor::Body), Node { flex_grow: 1.0, min_width: px(0), @@ -962,7 +948,7 @@ fn on_inventory_row( let Ok(row) = q_row.get(activate.entity) else { return; }; - if select_inventory_row(&mut runtime, &ships, *row) { + if select_inventory_row(&mut runtime, &ships, row.clone()) { play_menu_select(&mut commands, bank.as_deref()); } } @@ -976,13 +962,13 @@ fn select_inventory_row( ships: &InventoryShips, row: InventoryRow, ) -> bool { - let picked = Some((row.side, row.item)); + let picked = Some((row.side, row.item.clone())); let pair = ships.pair(); let partner_lootable = pair.and_then(|pair| ships.partner_lootable(pair)); let offer = pair.and_then(|pair| { offered_action(row.side, partner_lootable, ships.intake(pair.own).is_some()) }); - let kept = match (runtime.draft, offer) { + let kept = match (runtime.draft.as_ref(), offer) { (Some(draft), Some(action)) => { draft.item == row.item && draft_fits(action, draft.action, partner_lootable) } @@ -1020,6 +1006,7 @@ pub(crate) fn inventory_keys( input: NovaOsAppInput, bindings: Res, ships: InventoryShips, + items: Res, mut runtime: ResMut, bank: Option>>, mut commands: Commands, @@ -1073,17 +1060,20 @@ pub(crate) fn inventory_keys( .ship(ship) .1 .stacks() - .map(move |(item, _)| InventoryRow { side, item }) + .map(move |(item, _)| InventoryRow { + side, + item: item.clone(), + }) .collect::>() }) - .filter(|row| filter.is_none_or(|category| row.item.category() == category)) + .filter(|row| filter.is_none_or(|category| items.design(&row.item).category == category)) .collect(); - let current = runtime.selected.and_then(|(side, item)| { + let current = runtime.selected.clone().and_then(|(side, item)| { rows.iter() - .position(|row| *row == InventoryRow { side, item }) + .position(|row| row.side == side && row.item == item) }); if let Some(next) = cycle_index(current, rows.len(), forward) { - if select_inventory_row(&mut runtime, &ships, rows[next]) { + if select_inventory_row(&mut runtime, &ships, rows[next].clone()) { play_menu_select(&mut commands, bank.as_deref()); } } @@ -1137,7 +1127,7 @@ fn on_inventory_draft_action_chip( bank: Option>>, mut commands: Commands, ) { - let (Ok(chip), Some(draft)) = (q_chip.get(activate.entity), runtime.draft) else { + let (Ok(chip), Some(draft)) = (q_chip.get(activate.entity), runtime.draft.clone()) else { return; }; let switchable = matches!( @@ -1174,6 +1164,7 @@ fn on_take_credits_button( /// on a broken record. #[derive(SystemParam)] pub(crate) struct InventoryShips<'w, 's> { + items: Res<'w, GameItems>, players: Query<'w, 's, (Entity, Option<&'static DockedShip>), With>, connections: Query<'w, 's, &'static DockingConnection>, ships: Query< @@ -1295,7 +1286,7 @@ impl InventoryShips<'_, '_> { /// What the source ship of `draft` carries of its item, and never less than /// one, so the wheel's range is never empty. - fn draft_stock(&self, draft: InventoryDraft) -> u32 { + fn draft_stock(&self, draft: &InventoryDraft) -> u32 { let source = self.pair().and_then(|pair| match draft.action { InventoryActionType::Take | InventoryActionType::Buy => pair.partner, InventoryActionType::Give @@ -1303,7 +1294,7 @@ impl InventoryShips<'_, '_> { | InventoryActionType::Jettison => Some(pair.own), }); source - .map_or(0, |ship| self.ship(ship).1.count(draft.item)) + .map_or(0, |ship| self.ship(ship).1.count(&draft.item)) .max(1) } } @@ -1325,7 +1316,7 @@ fn set_draft_quantity( commands: &mut Commands, bank: Option<&SoundBank>, ) { - let Some(draft) = runtime.draft else { + let Some(draft) = runtime.draft.clone() else { return; }; if draft.quantity == quantity { @@ -1346,14 +1337,14 @@ fn wheel_inventory_draft( mut commands: Commands, mut runtime: ResMut, ) { - let Some(draft) = runtime.draft else { + let Some(draft) = runtime.draft.clone() else { return; }; if scroll.y == 0.0 { return; } let step = if scroll.y > 0.0 { 1 } else { -1 }; - let have = i64::from(ships.draft_stock(draft)); + let have = i64::from(ships.draft_stock(&draft)); let quantity = (i64::from(draft.quantity.unwrap_or(0)) + step).clamp(1, have) as u32; set_draft_quantity(&mut runtime, Some(quantity), &mut commands, bank.as_deref()); } @@ -1379,11 +1370,11 @@ fn fill_inventory_draft( mut commands: Commands, mut runtime: ResMut, ) { - let Some(draft) = runtime.draft else { + let Some(draft) = runtime.draft.clone() else { return; }; runtime.draft = Some(InventoryDraft { - quantity: Some(ships.draft_stock(draft)), + quantity: Some(ships.draft_stock(&draft)), ..draft }); play_menu_select(&mut commands, bank.as_deref()); @@ -1402,7 +1393,7 @@ fn confirm_inventory_draft( if q_disabled.contains(activate.entity) { return; } - if let Some(draft) = runtime.draft { + if let Some(draft) = runtime.draft.clone() { actions.write(InventoryActionCommand { action: draft.action, item: draft.item, @@ -1455,7 +1446,7 @@ pub(crate) fn sync_inventory_draft_controls( With, >, ) { - let Some(draft) = runtime.draft else { + let Some(draft) = runtime.draft.clone() else { for (field, _, focused, _) in &fields { if focused { commands.entity(field).remove::(); @@ -1463,7 +1454,7 @@ pub(crate) fn sync_inventory_draft_controls( } return; }; - let have = ships.draft_stock(draft); + let have = ships.draft_stock(&draft); for (slider, value, range, mut node) in &mut sliders { let display = if have > 1 { Display::Flex @@ -1525,6 +1516,7 @@ pub(crate) fn sync_inventory_draft_controls( pub(crate) fn apply_inventory_action_commands( mut actions: MessageReader, mut ships: InventoryShips, + items: Res, mut runtime: ResMut, bank: Option>>, mut commands: Commands, @@ -1537,9 +1529,18 @@ pub(crate) fn apply_inventory_action_commands( let mut pending = BTreeMap::new(); for command in actions.read() { let result = match (command.action.transfer(), command.action.trade()) { - (Some(transfer), _) => transfer_items(&mut ships, pair, transfer, *command), - (None, Some(trade)) => trade_items(&mut ships, pair, trade, *command), - (None, None) => jettison_items(&mut ships, pair, *command, &mut pending, &mut commands), + (Some(transfer), _) => { + transfer_items(&items, &mut ships, pair, transfer, command.clone()) + } + (None, Some(trade)) => trade_items(&items, &mut ships, pair, trade, command.clone()), + (None, None) => jettison_items( + &items, + &mut ships, + pair, + command.clone(), + &mut pending, + &mut commands, + ), }; let (note, cue, volume) = match result { Ok(note) => { @@ -1621,6 +1622,7 @@ fn credit_take_refusal_text(refusal: CreditTakeRefusalType, partner_title: &str) /// Move one command's items, or say why not. Both texts are the note line. fn transfer_items( + items: &GameItems, ships: &mut InventoryShips, pair: InventoryPair, transfer: ItemTransferType, @@ -1630,7 +1632,7 @@ fn transfer_items( return Err("Refused: not docked".to_string()); }; let InventoryActionCommand { item, quantity, .. } = command; - let label = item.label(); + let label = &items.design(&item).name; let own_title = ships.title(pair.own, InventorySideType::Own); let partner_title = ships.title(partner, InventorySideType::Partner); let (source, target, source_title, target_title) = match transfer { @@ -1639,16 +1641,23 @@ fn transfer_items( }; let (_, own, ..) = ships.ship(pair.own); let (_, theirs, lootable, _) = ships.ship(partner); - let moved = - plan_item_transfer(transfer, lootable, item, quantity, own, theirs).map_err(|refusal| { - transfer_refusal_text(refusal, item, source_title, target_title, &partner_title) + let moved = plan_item_transfer(items, transfer, lootable, &item, quantity, own, theirs) + .map_err(|refusal| { + transfer_refusal_text( + items, + refusal, + &item, + source_title, + target_title, + &partner_title, + ) })?; let [(_, mut from, ..), (_, mut to, ..)] = ships .ships .get_many_mut([source, target]) .expect("InventoryShips::pair checked both ships, and a ship does not dock with itself"); - from.remove(item, moved); - to.add(item, moved); + from.remove(&item, moved); + to.add(items, &item, moved); Ok(match transfer { ItemTransferType::Take => format!("Took {moved} {label} from {partner_title}"), ItemTransferType::Give => format!("Gave {moved} {label} to {partner_title}"), @@ -1658,6 +1667,7 @@ fn transfer_items( /// Trade one command's items for credits with the trading partner, or say why /// not. Both texts are the note line. fn trade_items( + items: &GameItems, ships: &mut InventoryShips, pair: InventoryPair, trade: ItemTradeType, @@ -1667,7 +1677,7 @@ fn trade_items( return Err("Refused: not docked".to_string()); }; let InventoryActionCommand { item, quantity, .. } = command; - let label = item.label(); + let label = &items.design(&item).name; let own_title = ships.title(pair.own, InventorySideType::Own); let partner_title = ships.title(partner, InventorySideType::Partner); let (seller, buyer, seller_title, buyer_title) = match trade { @@ -1677,17 +1687,24 @@ fn trade_items( let (_, own, _, own_cr) = ships.ship(pair.own); let (_, theirs, lootable, partner_cr) = ships.ship(partner); let planned = plan_item_trade( - trade, !lootable, item, quantity, own, own_cr, theirs, partner_cr, + items, trade, !lootable, &item, quantity, own, own_cr, theirs, partner_cr, ) .map_err(|refusal| { - trade_refusal_text(refusal, item, seller_title, buyer_title, &partner_title) + trade_refusal_text( + items, + refusal, + &item, + seller_title, + buyer_title, + &partner_title, + ) })?; let [(_, mut from, _, _, mut from_cr), (_, mut to, _, _, mut to_cr)] = ships .ships .get_many_mut([seller, buyer]) .expect("InventoryShips::pair checked both ships, and a ship does not dock with itself"); - from.remove(item, planned.count); - to.add(item, planned.count); + from.remove(&item, planned.count); + to.add(items, &item, planned.count); to_cr.0 -= planned.price_cr; from_cr.0 += planned.price_cr; let price = cr_text(planned.price_cr); @@ -1709,13 +1726,14 @@ fn trade_items( /// The note text of a refused Take or Give. fn transfer_refusal_text( + items: &GameItems, refusal: ItemTransferRefusalType, - item: ItemType, + item: &ItemDesignId, source_title: &str, target_title: &str, partner_title: &str, ) -> String { - let label = item.label(); + let label = &items.design(item).name; match refusal { ItemTransferRefusalType::NotLootable => { format!("Refused: {partner_title} is not neutralized or lootable") @@ -1734,13 +1752,14 @@ fn transfer_refusal_text( /// The note text of a refused Buy or Sell. fn trade_refusal_text( + items: &GameItems, refusal: ItemTradeRefusalType, - item: ItemType, + item: &ItemDesignId, seller_title: &str, buyer_title: &str, partner_title: &str, ) -> String { - let label = item.label(); + let label = &items.design(item).name; match refusal { ItemTradeRefusalType::NotTrader => format!("Refused: {partner_title} does not trade"), ItemTradeRefusalType::NoQuantity => "Refused: enter a quantity".to_string(), @@ -1764,6 +1783,7 @@ fn trade_refusal_text( /// Queue one command's items on the player's cargo intake as canisters, or say /// why not. Both texts are the note line. `pending` tracks deferred queues. fn jettison_items( + items: &GameItems, ships: &mut InventoryShips, pair: InventoryPair, command: InventoryActionCommand, @@ -1771,7 +1791,7 @@ fn jettison_items( commands: &mut Commands, ) -> Result { let InventoryActionCommand { item, quantity, .. } = command; - let label = item.label(); + let label = &items.design(&item).name; let own_title = ships.title(pair.own, InventorySideType::Own); let intake = ships.intake(pair.own); let tail = intake @@ -1779,10 +1799,11 @@ fn jettison_items( .and_then(|(entity, waiting)| pending.get(entity).unwrap_or(waiting).back()); let (_, own, ..) = ships.ship(pair.own); let jettison = plan_item_jettison( + items, pair.partner.is_some(), intake.is_some(), tail, - item, + &item, quantity, own, ) @@ -1804,13 +1825,13 @@ fn jettison_items( .ships .get_mut(pair.own) .expect("InventoryShips::pair checked the player ship"); - own.remove(item, jettison.count); + own.remove(&item, jettison.count); let mut queue = pending.remove(&intake).unwrap_or(waiting); if jettison.merged > 0 { queue .back_mut() .expect("plan_item_jettison merges only into a waiting canister") - .add(item, jettison.merged); + .add(items, &item, jettison.merged); } queue.extend(jettison.canisters); let waiting = queue.len(); @@ -1834,15 +1855,15 @@ fn jettison_items( struct SideView { title: String, credits: String, - stacks: Option>, + stacks: Option>, } impl SideView { - fn count(&self, item: ItemType) -> Option { + fn count(&self, item: &ItemDesignId) -> Option { self.stacks .as_ref()? .iter() - .find(|(each, _)| *each == item) + .find(|(each, _)| each == item) .map(|(_, count)| *count) } } @@ -1903,6 +1924,7 @@ pub(crate) fn update_inventory_panel( let Some(pair) = ships.pair() else { return; }; + let items: &GameItems = &ships.items; if let Some((_, remaining)) = runtime.note.as_mut() { *remaining -= time.delta_secs(); if *remaining <= 0.0 { @@ -1913,13 +1935,18 @@ pub(crate) fn update_inventory_panel( let own_title = ships.title(pair.own, InventorySideType::Own); let own_cargo = format!( "{} / {}", - kg_text(u64::from(own_inventory.used_g())), + kg_text(u64::from(own_inventory.used_g(&items))), kg_text(u64::from(own_inventory.capacity_g())), ); let own = SideView { title: own_title, credits: cr_text(own_cr), - stacks: Some(own_inventory.stacks().collect()), + stacks: Some( + own_inventory + .stacks() + .map(|(item, count)| (item.clone(), count)) + .collect(), + ), }; let mut partner_credits = None; let partner = match pair.partner { @@ -1930,7 +1957,12 @@ pub(crate) fn update_inventory_panel( SideView { title, credits: cr_text(credits), - stacks: Some(inventory.stacks().collect()), + stacks: Some( + inventory + .stacks() + .map(|(item, count)| (item.clone(), count)) + .collect(), + ), } } None => SideView { @@ -1948,16 +1980,17 @@ pub(crate) fn update_inventory_panel( }; let filter = runtime.filter; - let shown = |item: ItemType| filter.is_none_or(|category| item.category() == category); - if let Some((which, item)) = runtime.selected { - if side(which).count(item).is_none() || !shown(item) { + let shown = + |item: &ItemDesignId| filter.is_none_or(|category| items.design(item).category == category); + if let Some((which, item)) = runtime.selected.clone() { + if side(which).count(&item).is_none() || !shown(&item) { runtime.selected = None; runtime.details = false; } } - let selected = runtime.selected; - if let Some(draft) = runtime.draft { - let offer = selected.and_then(|(which, item)| { + let selected = runtime.selected.clone(); + if let Some(draft) = runtime.draft.clone() { + let offer = selected.clone().and_then(|(which, item)| { offered_action(which, partner_lootable, has_intake).map(|action| (action, item)) }); let fits = offer.is_some_and(|(action, item)| { @@ -1967,7 +2000,7 @@ pub(crate) fn update_inventory_panel( runtime.draft = None; } } - let draft = runtime.draft; + let draft = runtime.draft.clone(); for (list, mut column) in &mut q_column { let view = side(column.side); @@ -1988,10 +2021,10 @@ pub(crate) fn update_inventory_panel( rows.spawn(themed_label("Inventory empty.", 12.0, UiColor::Label)); return; } - let visible: Vec<(ItemType, u32)> = stacks + let visible: Vec<(ItemDesignId, u32)> = stacks .iter() - .copied() - .filter(|(item, _)| shown(*item)) + .cloned() + .filter(|(item, _)| shown(item)) .collect(); if visible.is_empty() { rows.spawn(themed_label( @@ -2001,8 +2034,10 @@ pub(crate) fn update_inventory_panel( )); } for stack in visible { - let picked = selected == Some((which, stack.0)); - inventory_row(rows, which, stack, picked, &icons); + let picked = selected + .as_ref() + .is_some_and(|(side, id)| *side == which && *id == stack.0); + inventory_row(rows, which, stack, picked, &icons, &items); } }); column.drawn = Some(draw); @@ -2042,7 +2077,10 @@ pub(crate) fn update_inventory_panel( } for (row, mut fill, mut border) in &mut q_row { - let (fill_alpha, border_alpha) = row_alphas(selected == Some((row.side, row.item))); + let picked = selected + .as_ref() + .is_some_and(|(side, item)| *side == row.side && *item == row.item); + let (fill_alpha, border_alpha) = row_alphas(picked); if fill.alpha != fill_alpha { fill.alpha = fill_alpha; } @@ -2064,7 +2102,7 @@ pub(crate) fn update_inventory_panel( } for (chip, mut fill, mut border) in &mut q_action_chip { - let current = draft.is_some_and(|draft| draft.action == chip.0); + let current = draft.as_ref().is_some_and(|draft| draft.action == chip.0); let (color, fill_alpha, border_alpha) = chip_paint(current); if fill.color != color || fill.alpha != fill_alpha { fill.color = color; @@ -2086,7 +2124,7 @@ pub(crate) fn update_inventory_panel( InspectorPart::Form => (draft.is_some(), false), InspectorPart::ActionChips => ( partner_lootable == Some(false) - && draft.is_some_and(|draft| { + && draft.as_ref().is_some_and(|draft| { matches!( draft.action, InventoryActionType::Give | InventoryActionType::Sell @@ -2094,9 +2132,10 @@ pub(crate) fn update_inventory_panel( }), true, ), - InspectorPart::TotalWeight => { - (draft.is_some_and(|draft| draft.quantity.is_some()), true) - } + InspectorPart::TotalWeight => ( + draft.as_ref().is_some_and(|draft| draft.quantity.is_some()), + true, + ), // `update_inventory_about` owns the description's parts. InspectorPart::AboutFull | InspectorPart::AboutMore => continue, }; @@ -2138,14 +2177,14 @@ pub(crate) fn update_inventory_panel( let picked = selected.map(|(which, item)| { let view = side(which); let count = view - .count(item) + .count(&item) .expect("the selection was cleared above unless its side carries the item"); (item, format!("x{count} in {}", view.title)) }); - let total_weight = draft.and_then(|draft| { - draft - .quantity - .map(|quantity| kg_text(u64::from(draft.item.mass_g()) * u64::from(quantity))) + let total_weight = draft.clone().and_then(|draft| { + draft.quantity.map(|quantity| { + kg_text(u64::from(items.design(&draft.item).mass_g) * u64::from(quantity)) + }) }); let form = draft.map(|draft| { let (source, title) = match draft.action { @@ -2155,7 +2194,7 @@ pub(crate) fn update_inventory_panel( InventoryActionType::Sell => (&own, format!("Sell to {}", partner.title)), InventoryActionType::Jettison => (&own, "Jettison".to_string()), }; - let have = source.count(draft.item).unwrap_or(0); + let have = source.count(&draft.item).unwrap_or(0); let summary = match (draft.quantity, pair.partner) { (None, _) => Err("Type a whole number".to_string()), // A jettison leaves the player's own stack. @@ -2171,9 +2210,10 @@ pub(crate) fn update_inventory_panel( let (_, their_stock, lootable, their_cr) = ships.ship(other); match (draft.action.transfer(), draft.action.trade()) { (Some(transfer), _) => plan_item_transfer( + &items, transfer, lootable, - draft.item, + &draft.item, Some(quantity), own_stock, their_stock, @@ -2183,7 +2223,7 @@ pub(crate) fn update_inventory_panel( ItemTransferType::Take => &own, ItemTransferType::Give => &partner, }; - let count = after.count(draft.item).unwrap_or(0).saturating_add(moved); + let count = after.count(&draft.item).unwrap_or(0).saturating_add(moved); format!("{} after: x{count}", after.title) }) .map_err(|refusal| { @@ -2191,12 +2231,20 @@ pub(crate) fn update_inventory_panel( ItemTransferType::Take => (&partner.title, &own.title), ItemTransferType::Give => (&own.title, &partner.title), }; - transfer_refusal_text(refusal, draft.item, from, to, &partner.title) + transfer_refusal_text( + &items, + refusal, + &draft.item, + from, + to, + &partner.title, + ) }), (None, Some(trade)) => plan_item_trade( + &items, trade, !lootable, - draft.item, + &draft.item, Some(quantity), own_stock, own_cr, @@ -2219,7 +2267,14 @@ pub(crate) fn update_inventory_panel( ItemTradeType::Buy => (&partner.title, &own.title), ItemTradeType::Sell => (&own.title, &partner.title), }; - trade_refusal_text(refusal, draft.item, seller, buyer, &partner.title) + trade_refusal_text( + &items, + refusal, + &draft.item, + seller, + buyer, + &partner.title, + ) }), (None, None) => Err("Refused: undock to jettison".to_string()), } @@ -2232,23 +2287,27 @@ pub(crate) fn update_inventory_panel( (InventoryInspectorField::Context, ..) => (context.to_string(), UiColor::Label), (InventoryInspectorField::Note, ..) => (note.clone(), UiColor::Accent), (InventoryInspectorField::Name, Some((item, _)), _) => { - (item.label().to_string(), UiColor::Primary) + (items.design(item).name.clone(), UiColor::Primary) + } + (InventoryInspectorField::Category, Some((item, _)), _) => { + let category = items.design(item).category; + ( + category_label(category).to_string(), + category_color(category), + ) } - (InventoryInspectorField::Category, Some((item, _)), _) => ( - category_label(item.category()).to_string(), - category_color(item.category()), - ), ( InventoryInspectorField::About | InventoryInspectorField::AboutFull, Some((item, _)), _, - ) => (item_about(*item).to_string(), UiColor::Body), + ) => (items.design(item).about.clone(), UiColor::Body), (InventoryInspectorField::Stock, Some((_, stock)), _) => { (stock.clone(), UiColor::Primary) } - (InventoryInspectorField::Weight, Some((item, _)), _) => { - (kg_text(u64::from(item.mass_g())), UiColor::Primary) - } + (InventoryInspectorField::Weight, Some((item, _)), _) => ( + kg_text(u64::from(items.design(item).mass_g)), + UiColor::Primary, + ), (InventoryInspectorField::TotalWeight, ..) => match &total_weight { Some(total) => (total.clone(), UiColor::Primary), None => continue, @@ -2285,9 +2344,10 @@ pub(crate) fn update_inventory_panel( let Some((item, _)) = picked else { return; }; - let tone = category_color(item.category()); + let category = items.design(&item).category; + let tone = category_color(category); for (mut image, mut tint) in &mut q_icon { - let wanted = icons.category(item.category()); + let wanted = icons.category(category); if image.image != wanted { image.image = wanted; } diff --git a/crates/nova_interface/src/inventory/mod.rs b/crates/nova_interface/src/inventory/mod.rs index c3f4442b0..7c251bce5 100644 --- a/crates/nova_interface/src/inventory/mod.rs +++ b/crates/nova_interface/src/inventory/mod.rs @@ -41,7 +41,9 @@ mod app; mod tests; use bevy::prelude::*; -use nova_gameplay::prelude::{ItemCategoryType, ItemTradeType, ItemTransferType, ItemType}; +use nova_gameplay::prelude::{ + GameItems, ItemCategoryType, ItemDesignId, ItemTradeType, ItemTransferType, +}; use nova_ui::input_mode::prelude::{in_input_mode, InputMode}; pub(crate) use self::app::*; @@ -54,6 +56,12 @@ impl Plugin for InventoryPanePlugin { app.init_resource::(); app.add_message::(); app.add_message::(); + // Every pane system reads the item catalog, which lands only at the mod + // merge and never on a refused base game; the set runs from boot. + app.configure_sets( + Update, + InventoryPaneSystems.run_if(resource_exists::), + ); app.add_systems( Update, ( @@ -95,7 +103,7 @@ pub(crate) struct InventoryRuntime { /// The one category the rows show; `None` shows every category. pub(crate) filter: Option, /// The row the inspector shows. - pub(crate) selected: Option<(InventorySideType, ItemType)>, + pub(crate) selected: Option<(InventorySideType, ItemDesignId)>, /// The action form the selected row opened, if it offers one. pub(crate) draft: Option, /// The last action result and its remaining seconds on screen. @@ -141,12 +149,12 @@ impl InventoryActionType { } /// An action waiting for its quantity to be confirmed. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug)] pub(crate) struct InventoryDraft { /// Take, Give, Buy, Sell or Jettison. pub(crate) action: InventoryActionType, /// The item to move. - pub(crate) item: ItemType, + pub(crate) item: ItemDesignId, /// The one quantity the wheel, the slider, the field and All set. `None` /// while the field holds text that is not a whole number, which Confirm /// refuses. @@ -155,12 +163,12 @@ pub(crate) struct InventoryDraft { /// A confirmed action on the player ship's inventory, written only by the /// form's Confirm and applied by [`apply_inventory_action_commands`]. -#[derive(Message, Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Message, Clone, PartialEq, Eq, Debug)] pub(crate) struct InventoryActionCommand { /// Take, Give, Buy, Sell or Jettison. pub(crate) action: InventoryActionType, /// The item to move. - pub(crate) item: ItemType, + pub(crate) item: ItemDesignId, /// The quantity; `None` when the typed text is not a whole number. pub(crate) quantity: Option, } diff --git a/crates/nova_interface/src/inventory/tests.rs b/crates/nova_interface/src/inventory/tests.rs index 94ce3b4e5..da9fd4ed0 100644 --- a/crates/nova_interface/src/inventory/tests.rs +++ b/crates/nova_interface/src/inventory/tests.rs @@ -10,7 +10,7 @@ use bevy::{ ui::{ComputedNode, InteractionDisabled, UiGlobalTransform}, ui_widgets::{Activate, ValueChange}, }; -use nova_gameplay::prelude::*; +use nova_gameplay::{prelude::*, test_support::test_items}; use nova_input::prelude::RegisterInputActions; use nova_ship::prelude::{ CargoIntakeEjectionQueue, CargoIntakeSectionMarker, DockedHelmType, DockedShip, @@ -44,7 +44,11 @@ fn spawn_inventory_body(app: &mut App, parent: Entity) { /// A 400 kg hold carrying `plates` hull plates of 10 kg. fn hold(plates: u32) -> ShipInventory { - ShipInventory::new(400_000, [(ItemType::HullPlate, plates)]) + ShipInventory::new( + &test_items(), + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), plates)], + ) } /// A player ship carrying `plates` hull plates. @@ -162,6 +166,7 @@ fn inventory_columns_show_the_player_and_docked_partner_stacks() { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.insert_resource(InterfaceIcons::blank()); + app.insert_resource(test_items()); app.init_resource::(); app.add_systems(Update, update_inventory_panel); let root = app.world_mut().spawn(Node::default()).id(); @@ -260,6 +265,7 @@ fn inventory_rig() -> (PanePointerRig, Entity) { rig.app .register_input_actions(crate::bindings::interface_bindings()); rig.app.insert_resource(InterfaceIcons::blank()); + rig.app.insert_resource(test_items()); rig.app.add_plugins(InventoryPanePlugin); hear_ui_cues(&mut rig.app); track_node_churn(&mut rig.app); @@ -327,13 +333,13 @@ fn a_dock_ending_mid_trade_hides_the_partner_column_and_closes_its_form() { let partner_row = centre_of::(rig.app.world_mut(), |row| { *row == InventoryRow { side: InventorySideType::Partner, - item: ItemType::HullPlate, + item: ItemDesignId::from(ITEM_HULL_PLATE), } }); click_at(&mut rig, partner_row); let runtime = rig.app.world().resource::(); assert_eq!( - runtime.draft.map(|draft| draft.action), + runtime.draft.as_ref().map(|draft| draft.action), Some(InventoryActionType::Buy), "the trading partner's row opens Buy" ); @@ -356,7 +362,10 @@ fn a_dock_ending_mid_trade_hides_the_partner_column_and_closes_its_form() { assert!(!partner_drawn, "the partner column is hidden"); assert!(column_texts(rig.app.world_mut(), InventorySideType::Partner).is_empty()); let runtime = rig.app.world().resource::(); - assert_eq!((runtime.selected, runtime.draft), (None, None)); + assert_eq!( + (runtime.selected.clone(), runtime.draft.clone()), + (None, None) + ); } #[test] @@ -391,7 +400,7 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() { .world_mut() .get_mut::(partner) .expect("the partner has a hold") - .add(ItemType::PdcRound, 7); + .add(&test_items(), &ItemDesignId::from(ITEM_PDC_ROUND), 7); settle(&mut rig.app); take_churn(&mut rig.app); let rows = row_entities(rig.app.world_mut()); @@ -400,13 +409,16 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() { let partner_row = centre_of::(rig.app.world_mut(), |row| { *row == InventoryRow { side: InventorySideType::Partner, - item: ItemType::HullPlate, + item: ItemDesignId::from(ITEM_HULL_PLATE), } }); click_at(&mut rig, partner_row); assert_eq!( rig.app.world().resource::().selected, - Some((InventorySideType::Partner, ItemType::HullPlate)) + Some(( + InventorySideType::Partner, + ItemDesignId::from(ITEM_HULL_PLATE) + )) ); assert_eq!(take_cues(&mut rig.app), [UiSfx::MenuSelect]); // Selecting repaints the rows in place: none is spawned or despawned. @@ -449,6 +461,7 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() { world .resource::() .draft + .as_ref() .map(|draft| draft.action), Some(InventoryActionType::Buy) ); @@ -463,6 +476,7 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() { .world() .resource::() .draft + .as_ref() .map(|draft| draft.action), Some(InventoryActionType::Sell) ); @@ -475,7 +489,10 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() { let runtime = rig.app.world().resource::().clone(); assert_eq!( runtime.selected, - Some((InventorySideType::Partner, ItemType::HullPlate)) + Some(( + InventorySideType::Partner, + ItemDesignId::from(ITEM_HULL_PLATE) + )) ); assert_eq!( runtime.draft.map(|draft| draft.action), @@ -498,12 +515,12 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() { assert!(full_shown(rig.app.world_mut())); assert_eq!( inspector(rig.app.world_mut(), InventoryInspectorField::AboutFull).as_deref(), - Some("Structural plating for hull sections.") + Some("Hull plate for tests.") ); press_key(&mut rig.app, KeyCode::BracketLeft); assert_eq!( rig.app.world().resource::().selected, - Some((InventorySideType::Own, ItemType::HullPlate)) + Some((InventorySideType::Own, ItemDesignId::from(ITEM_HULL_PLATE))) ); assert!(!full_shown(rig.app.world_mut())); take_cues(&mut rig.app); @@ -528,7 +545,7 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() { let rounds_row = centre_of::(rig.app.world_mut(), |row| { *row == InventoryRow { side: InventorySideType::Partner, - item: ItemType::PdcRound, + item: ItemDesignId::from(ITEM_PDC_ROUND), } }); click_at(&mut rig, rounds_row); @@ -568,6 +585,7 @@ fn a_player_ship_without_an_inventory_panics_the_panel() { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.insert_resource(InterfaceIcons::blank()); + app.insert_resource(test_items()); app.init_resource::(); app.add_systems(Update, update_inventory_panel); let root = app.world_mut().spawn(Node::default()).id(); @@ -590,7 +608,7 @@ fn plates(world: &World, ship: Entity) -> u32 { world .get::(ship) .expect("a ship root carries a ShipInventory") - .count(ItemType::HullPlate) + .count(&ItemDesignId::from(ITEM_HULL_PLATE)) } /// The note line's text, if a result is showing. @@ -608,6 +626,7 @@ fn confirm_moves_items_between_docked_ships_and_a_refusal_changes_nothing() { let mut app = App::new(); app.add_plugins((MinimalPlugins, AssetPlugin::default())); + app.insert_resource(test_items()); app.init_resource::(); app.add_message::(); app.add_systems(Update, apply_inventory_action_commands); @@ -620,12 +639,12 @@ fn confirm_moves_items_between_docked_ships_and_a_refusal_changes_nothing() { let confirm = |app: &mut App, action, quantity: Option| { let command = InventoryActionCommand { action, - item: ItemType::HullPlate, + item: ItemDesignId::from(ITEM_HULL_PLATE), quantity, }; app.world_mut().resource_mut::().draft = Some(InventoryDraft { action, - item: ItemType::HullPlate, + item: ItemDesignId::from(ITEM_HULL_PLATE), quantity, }); app.world_mut().write_message(command); @@ -734,6 +753,7 @@ fn confirm_trades_items_for_credits_and_a_refusal_changes_nothing() { let mut app = App::new(); app.add_plugins((MinimalPlugins, AssetPlugin::default())); + app.insert_resource(test_items()); app.init_resource::(); app.add_message::(); app.add_systems(Update, apply_inventory_action_commands); @@ -756,12 +776,12 @@ fn confirm_trades_items_for_credits_and_a_refusal_changes_nothing() { let confirm = |app: &mut App, action, quantity: Option| { app.world_mut().resource_mut::().draft = Some(InventoryDraft { action, - item: ItemType::HullPlate, + item: ItemDesignId::from(ITEM_HULL_PLATE), quantity, }); app.world_mut().write_message(InventoryActionCommand { action, - item: ItemType::HullPlate, + item: ItemDesignId::from(ITEM_HULL_PLATE), quantity, }); app.update(); @@ -805,7 +825,11 @@ fn confirm_trades_items_for_credits_and_a_refusal_changes_nothing() { // The buyer's hold binds before its credits. app.world_mut() .entity_mut(partner) - .insert(ShipInventory::new(100_000, [(ItemType::HullPlate, 9)])); + .insert(ShipInventory::new( + &test_items(), + 100_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), 9)], + )); assert_eq!( confirm(&mut app, Sell, Some(2)), refused("Refused: Trader has room for 10 kg more") @@ -830,6 +854,7 @@ fn confirm_trades_items_for_credits_and_a_refusal_changes_nothing() { fn take_credits_moves_the_whole_balance_once_and_a_refusal_changes_nothing() { let mut app = App::new(); app.add_plugins((MinimalPlugins, AssetPlugin::default())); + app.insert_resource(test_items()); app.init_resource::(); app.add_message::(); app.add_systems(Update, apply_credit_take_commands); @@ -1052,6 +1077,7 @@ fn confirm_is_disabled_while_the_draft_is_refused_and_enabled_at_exact_room_and_ fn confirm_jettisons_through_the_intake_and_a_refusal_changes_nothing() { let mut app = App::new(); app.add_plugins((MinimalPlugins, AssetPlugin::default())); + app.insert_resource(test_items()); app.init_resource::(); app.add_message::(); app.add_systems(Update, apply_inventory_action_commands); @@ -1059,7 +1085,7 @@ fn confirm_jettisons_through_the_intake_and_a_refusal_changes_nothing() { let player = spawn_player(app.world_mut(), 12); let command = |quantity: Option| InventoryActionCommand { action: InventoryActionType::Jettison, - item: ItemType::HullPlate, + item: ItemDesignId::from(ITEM_HULL_PLATE), quantity, }; // Write confirmed commands with their draft open, and run them. @@ -1067,7 +1093,7 @@ fn confirm_jettisons_through_the_intake_and_a_refusal_changes_nothing() { for &quantity in quantities { app.world_mut().resource_mut::().draft = Some(InventoryDraft { action: InventoryActionType::Jettison, - item: ItemType::HullPlate, + item: ItemDesignId::from(ITEM_HULL_PLATE), quantity, }); app.world_mut().write_message(command(quantity)); @@ -1081,7 +1107,13 @@ fn confirm_jettisons_through_the_intake_and_a_refusal_changes_nothing() { let queue = |app: &App, intake: Entity| { app.world() .get::(intake) - .map(|queue| queue.0.iter().map(CargoCanister::total_mass_g).collect()) + .map(|queue| { + queue + .0 + .iter() + .map(|canister| canister.total_mass_g(&test_items())) + .collect() + }) }; assert_eq!( @@ -1134,7 +1166,7 @@ fn confirm_jettisons_through_the_intake_and_a_refusal_changes_nothing() { app.world_mut() .entity_mut(intake) .insert(CargoIntakeEjectionQueue(VecDeque::from([ - CargoCanister::new(ItemType::HullPlate, 19), + CargoCanister::new(&test_items(), &ItemDesignId::from(ITEM_HULL_PLATE), 19), ]))); assert_eq!( confirm(&mut app, &[Some(40)]), @@ -1186,10 +1218,155 @@ fn draft_quantity(app: &App) -> Option { app.world() .resource::() .draft + .as_ref() .expect("a draft is open") .quantity } +/// Before the mod merge publishes the item catalog, the pane's systems wait +/// and the app runs on; once the catalog lands, a confirmed jettison moves +/// the stock into the intake's queue. +#[test] +fn the_inventory_pane_waits_for_the_merged_item_catalog() { + let (mut rig, player) = inventory_rig(); + let catalog = rig + .app + .world_mut() + .remove_resource::() + .expect("the rig starts with a catalog"); + let intake = rig + .app + .world_mut() + .spawn((ChildOf(player), CargoIntakeSectionMarker)) + .id(); + settle(&mut rig.app); + assert_eq!(plates(rig.app.world(), player), 12); + + rig.app.insert_resource(catalog); + let jettison = InventoryActionType::Jettison; + let item = ItemDesignId::from(ITEM_HULL_PLATE); + rig.app.world_mut().resource_mut::().draft = Some(InventoryDraft { + action: jettison, + item: item.clone(), + quantity: Some(4), + }); + rig.app.world_mut().write_message(InventoryActionCommand { + action: jettison, + item, + quantity: Some(4), + }); + rig.app.update(); + assert_eq!(plates(rig.app.world(), player), 8); + let queued: Vec = rig + .app + .world() + .get::(intake) + .expect("the jettison queues a canister on the intake") + .0 + .iter() + .map(|canister| canister.total_mass_g(&test_items())) + .collect(); + assert_eq!(queued, [40_000]); +} + +/// An item only the content catalog defines, which no engine code names, +/// leaves the hold through a jettison into the intake's waiting canister and +/// trades at its authored prices. Count and credits are conserved. +#[test] +fn a_catalog_only_item_jettisons_sells_and_buys_through_the_pane_commands() { + use InventoryActionType::{Buy, Jettison, Sell}; + + let core = ItemDesignId::from("fixture_core"); + let items = GameItems::new(test_items().iter().cloned().chain([ItemDesign { + id: core.clone(), + name: "Fixture core".to_string(), + about: "A catalog-only item for tests.".to_string(), + category: ItemCategoryType::Parts, + mass_g: 5_000, + ask_cr: 60, + bid_cr: 45, + }])); + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())); + app.insert_resource(items.clone()); + app.init_resource::(); + app.add_message::(); + app.add_systems(Update, apply_inventory_action_commands); + hear_ui_cues(&mut app); + let stock = |count: u32| ShipInventory::new(&items, 400_000, [(core.clone(), count)]); + // `hold` refuses a zero stack, so both ships start with one plate and + // take a core-only hold in its place. + let player = spawn_player(app.world_mut(), 1); + app.world_mut() + .entity_mut(player) + .insert((stock(6), ShipCredits(100))); + let intake = app + .world_mut() + .spawn((ChildOf(player), CargoIntakeSectionMarker)) + .id(); + let confirm = |app: &mut App, action, quantity: u32| { + app.world_mut().resource_mut::().draft = Some(InventoryDraft { + action, + item: core.clone(), + quantity: Some(quantity), + }); + app.world_mut().write_message(InventoryActionCommand { + action, + item: core.clone(), + quantity: Some(quantity), + }); + app.update(); + (note(app), take_cues(app)) + }; + let done = |text: &str| (Some(text.to_string()), vec![UiSfx::MenuSelect]); + let held = |app: &App, ship: Entity| { + let hold = app.world().get::(ship).unwrap(); + let credits = app.world().get::(ship).unwrap().0; + (hold.count(&core), hold.used_g(&items), credits) + }; + let queued = |app: &App| -> Vec<(Vec<(ItemDesignId, u32)>, u32)> { + app.world() + .get::(intake) + .expect("a jettison queues on the intake") + .0 + .iter() + .map(|canister| { + let stacks = canister + .stacks() + .map(|(item, count)| (item.clone(), count)) + .collect(); + (stacks, canister.total_mass_g(&items)) + }) + .collect() + }; + + assert_eq!( + confirm(&mut app, Jettison, 2), + done("Jettisoned 2 Fixture core: 1 canister queued") + ); + assert_eq!(held(&app, player), (4, 20_000, 100)); + assert_eq!(queued(&app), [(vec![(core.clone(), 2)], 10_000)]); + + // The core asks 60 cr and bids 45 cr. + let partner = dock_partner(app.world_mut(), player, "Trader", 1); + app.world_mut() + .entity_mut(partner) + .insert((stock(4), ShipCredits(1_000))); + assert_eq!( + confirm(&mut app, Sell, 3), + done("Sold 3 Fixture core to Trader for 135 cr") + ); + assert_eq!(held(&app, player), (1, 5_000, 235)); + assert_eq!(held(&app, partner), (7, 35_000, 865)); + assert_eq!( + confirm(&mut app, Buy, 2), + done("Bought 2 Fixture core from Trader for 120 cr") + ); + assert_eq!(held(&app, player), (3, 15_000, 115)); + assert_eq!(held(&app, partner), (5, 25_000, 985)); + assert_eq!(queued(&app), [(vec![(core.clone(), 2)], 10_000)]); +} + #[test] fn a_selected_row_opens_a_one_unit_draft_that_every_quantity_control_sets() { let (mut rig, player) = inventory_rig(); @@ -1210,7 +1387,7 @@ fn a_selected_row_opens_a_one_unit_draft_that_every_quantity_control_sets() { rig.app.world().resource::().draft, Some(InventoryDraft { action: InventoryActionType::Take, - item: ItemType::HullPlate, + item: ItemDesignId::from(ITEM_HULL_PLATE), quantity: Some(1), }) ); @@ -1375,6 +1552,7 @@ fn a_selected_row_opens_a_one_unit_draft_that_every_quantity_control_sets() { .world() .resource::() .draft + .as_ref() .map(|draft| draft.action), Some(InventoryActionType::Give) ); @@ -1468,7 +1646,7 @@ mod generated_wreck { } /// How many of `item` `ship` carries. - fn count(world: &World, ship: Entity, item: ItemType) -> u32 { + fn count(world: &World, ship: Entity, item: &ItemDesignId) -> u32 { world .get::(ship) .expect("a ship root carries a ShipInventory") @@ -1480,12 +1658,12 @@ mod generated_wreck { fn confirm( app: &mut App, action: InventoryActionType, - item: ItemType, + item: ItemDesignId, quantity: u32, ) -> Option { let command = InventoryActionCommand { action, - item, + item: item.clone(), quantity: Some(quantity), }; app.world_mut().resource_mut::().draft = Some(InventoryDraft { @@ -1523,6 +1701,7 @@ mod generated_wreck { generate_wreck(&snapshot, request).unwrap_or_else(|failure| panic!("{failure}")); let stock = ship_stock( &snapshot, + &test_items(), &wreck.design, request.role, SectorShipConditionType::Derelict, @@ -1533,6 +1712,7 @@ mod generated_wreck { let (drawn_item, drawn) = stock .stacks() .next() + .map(|(item, count)| (item.clone(), count)) .expect("ship_stock never returns empty stock"); let dock = wreck .design @@ -1583,6 +1763,7 @@ mod generated_wreck { )); app.insert_resource(GameSections(sections)); app.insert_resource(GameShipDesigns(ships)); + app.insert_resource(test_items()); app.init_resource::(); app.init_resource::(); app.add_message::(); @@ -1635,7 +1816,10 @@ mod generated_wreck { initial_velocity: MetersPerSecond3::ZERO, allegiance: None, capabilities: default(), - inventory: ShipInventoryStock::new([(ItemType::HullPlate, PLAYER_PLATES)]), + inventory: ShipInventoryStock::new([( + ItemDesignId::from(ITEM_HULL_PLATE), + PLAYER_PLATES, + )]), lootable: false, credits: 0, }), @@ -1662,7 +1846,7 @@ mod generated_wreck { assert!(!entity.contains::()); assert_eq!(entity.get::(), Some(&Allegiance::Neutral)); assert_eq!( - count(app.world(), wreck, drawn_item), + count(app.world(), wreck, &drawn_item), drawn, "the wreck spawns its stock" ); @@ -1670,22 +1854,28 @@ mod generated_wreck { .get::() .expect("the wreck is named") .to_string(); - let label = drawn_item.label(); + let items = test_items(); + let label = &items.design(&drawn_item).name; let held = |app: &App| { ( - count(app.world(), player, drawn_item), - count(app.world(), wreck, drawn_item), + count(app.world(), player, &drawn_item), + count(app.world(), wreck, &drawn_item), ) }; let before_take = held(&app).0; assert_eq!( - confirm(&mut app, InventoryActionType::Take, drawn_item, drawn), + confirm( + &mut app, + InventoryActionType::Take, + drawn_item.clone(), + drawn + ), Some(format!("Took {drawn} {label} from {name}")) ); assert_eq!(held(&app), (before_take + drawn, 0)); assert_eq!( - confirm(&mut app, InventoryActionType::Give, drawn_item, 1), + confirm(&mut app, InventoryActionType::Give, drawn_item.clone(), 1), Some(format!("Gave 1 {label} to {name}")) ); assert_eq!(held(&app), (before_take + drawn - 1, 1)); diff --git a/crates/nova_interface/src/ship/scene.rs b/crates/nova_interface/src/ship/scene.rs index 4d188360a..5a41430e6 100644 --- a/crates/nova_interface/src/ship/scene.rs +++ b/crates/nova_interface/src/ship/scene.rs @@ -561,7 +561,7 @@ pub(crate) fn ship_input( let stock = q_inventory .single() .ok() - .map(|stock| stock.count(ItemType::HullPlate)); + .map(|stock| stock.count(&ItemDesignId::from(ITEM_HULL_PLATE))); runtime.requested_plates = view.map(|view| { plate_repair_limit(view.health.as_ref(), view.disabled, stock.unwrap_or(0)) }); @@ -1020,7 +1020,7 @@ pub(crate) fn on_ship_repair_all_button( runtime.requested_plates = Some(plate_repair_limit( view.health.as_ref(), view.disabled, - stock.count(ItemType::HullPlate), + stock.count(&ItemDesignId::from(ITEM_HULL_PLATE)), )); } @@ -1045,7 +1045,7 @@ pub(crate) fn on_ship_repair_slider( let max = plate_repair_limit( view.health.as_ref(), view.disabled, - stock.count(ItemType::HullPlate), + stock.count(&ItemDesignId::from(ITEM_HULL_PLATE)), ); if max > 0 { runtime.requested_plates = Some(change.value.round().clamp(1.0, max as f32) as u32); @@ -1149,7 +1149,9 @@ pub(crate) fn update_ship_panel( // no-section state rather than panicking on a query the scene hasn't // caught up to yet. let inventory = q_inventory.single().ok(); - let stock = inventory.map_or(0, |inventory| inventory.count(ItemType::HullPlate)); + let stock = inventory.map_or(0, |inventory| { + inventory.count(&ItemDesignId::from(ITEM_HULL_PLATE)) + }); let selected = inventory.and(selected); // This system's own rewrite below does not read as a change on its next // run, so only the player's typing lands here. diff --git a/crates/nova_interface/src/ship/sections.rs b/crates/nova_interface/src/ship/sections.rs index 94e111b59..23838bb67 100644 --- a/crates/nova_interface/src/ship/sections.rs +++ b/crates/nova_interface/src/ship/sections.rs @@ -491,10 +491,10 @@ pub(crate) fn repair_section( Some(health), disabled, requested_plates, - inventory.count(ItemType::HullPlate), + inventory.count(&ItemDesignId::from(ITEM_HULL_PLATE)), ) { Ok(repair) => { - inventory.remove(ItemType::HullPlate, repair.plates); + inventory.remove(&ItemDesignId::from(ITEM_HULL_PLATE), repair.plates); health.current = repair.current; let noun = if repair.plates == 1 { "plate" diff --git a/crates/nova_interface/src/ship/tests.rs b/crates/nova_interface/src/ship/tests.rs index fd92a4547..4bb57384d 100644 --- a/crates/nova_interface/src/ship/tests.rs +++ b/crates/nova_interface/src/ship/tests.rs @@ -10,7 +10,7 @@ use bevy::{ ui_widgets::Activate, }; use nova_events::prelude::EntityId; -use nova_gameplay::{prelude::*, PauseStates}; +use nova_gameplay::{prelude::*, test_support::test_items, PauseStates}; use nova_input::prelude::RegisterInputActions; use nova_ship::prelude::*; use nova_ui::{ @@ -123,6 +123,7 @@ fn ship_repair_spends_plates_only_on_a_damaged_player_section() { // Repair spends exactly the selected whole plates, each restoring up to 20 // HP. Live stock and Health are validated before either mutates. Every // refusal leaves Health, markers and both ships' inventories untouched. + let items = test_items(); let mut app = App::new(); app.add_plugins(MinimalPlugins); app.init_resource::(); @@ -148,7 +149,7 @@ fn ship_repair_spends_plates_only_on_a_damaged_player_section() { app.world() .get::(ship) .unwrap() - .count(ItemType::HullPlate) + .count(&ItemDesignId::from(ITEM_HULL_PLATE)) }; let note = |app: &App| { app.world() @@ -157,7 +158,13 @@ fn ship_repair_spends_plates_only_on_a_damaged_player_section() { .clone() .map(|(text, _)| text) }; - let stock = |count| ShipInventory::new(400_000, [(ItemType::HullPlate, count)]); + let stock = |count| { + ShipInventory::new( + &items, + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), count)], + ) + }; // No plates: the P key still sends, and the hull stays at 80/100. repair(&mut app, &[(hull, 1)]); @@ -253,6 +260,7 @@ fn ship_repair_spends_plates_only_on_a_damaged_player_section() { #[test] fn ship_repair_spends_selected_quantity_and_refuses_stale_requests_atomically() { + let items = test_items(); let mut app = App::new(); app.add_plugins(MinimalPlugins); app.init_resource::(); @@ -263,13 +271,19 @@ fn ship_repair_spends_selected_quantity_and_refuses_stale_requests_atomically() .run_system_once(assign_section_codes) .unwrap(); - let stock = |count| ShipInventory::new(400_000, [(ItemType::HullPlate, count)]); + let stock = |count| { + ShipInventory::new( + &items, + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), count)], + ) + }; let health = |app: &App| app.world().get::(turret).unwrap().current; let plates = |app: &App| { app.world() .get::(ship) .unwrap() - .count(ItemType::HullPlate) + .count(&ItemDesignId::from(ITEM_HULL_PLATE)) }; let repair = |app: &mut App, requested_plates| { app.world_mut().write_message(SectionRepairCommand { @@ -780,6 +794,7 @@ fn ship_orbit_recenters_on_selected_section() { /// and starts the draft at All once the inventory arrives. #[test] fn ship_input_selection_repair_reset_survives_a_missing_player_inventory() { + let items = test_items(); let (mut app, hull, _turret, _thruster) = offset_ship_app(); let ship = app .world_mut() @@ -799,9 +814,11 @@ fn ship_input_selection_repair_reset_survives_a_missing_player_inventory() { "no inventory means no plates to request, not a panic" ); - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(400_000, [(ItemType::HullPlate, 10)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &items, + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), 10)], + )); app.world_mut().run_system_once(ship_input).unwrap(); let runtime = app.world().resource::(); assert_eq!(runtime.repair_target, Some(hull)); @@ -1199,6 +1216,7 @@ fn panel_buttons_raise_section_repair_command() { // (`pin-each-caller-not-just-shared-core`). // A world trigger leaves the observers' commands queued, so each cue check // flushes first; a silent check without the flush would pass vacuously. + let items = test_items(); let mut app = App::new(); app.add_plugins((MinimalPlugins, StatesPlugin, AssetPlugin::default())); app.insert_state(PauseStates::Interface); @@ -1214,9 +1232,11 @@ fn panel_buttons_raise_section_repair_command() { .unwrap(); // --- Repair button on the hull (starts at 80/100). --- - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(400_000, [(ItemType::HullPlate, 1)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &items, + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), 1)], + )); let repair = app .world_mut() .spawn(ShipPanelButton::Repair) @@ -1294,6 +1314,7 @@ fn update_ship_panel_reflects_selection() { // caches the button-enabled flags the observers read. Reverting it to a // no-op must fail this (the fact rows would stay empty and the flags stay // false). Scheduled rather than run once, so typing reads as a change. + let items = test_items(); let mut app = App::new(); app.add_plugins((MinimalPlugins, AssetPlugin::default())); app.init_asset::(); @@ -1301,9 +1322,11 @@ fn update_ship_panel_reflects_selection() { app.insert_resource(InterfaceIcons::blank()); app.add_systems(Update, update_ship_panel); let (ship, hull, turret, thruster) = spawn_scripted_ship(app.world_mut()); - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(400_000, [(ItemType::HullPlate, 1)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &items, + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), 1)], + )); app.world_mut() .entity_mut(turret) .insert(SpaceshipTurretInputBinding(vec![ @@ -1443,9 +1466,11 @@ fn update_ship_panel_reflects_selection() { // A section selected at full integrity has no draft of its own yet. The // damage that makes it repairable starts its draft at All, as in the // Ship lesson where the turret is hurt after the pane selects it. - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(400_000, [(ItemType::HullPlate, 10)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &items, + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), 10)], + )); app.world_mut().resource_mut::().selected = Some(thruster); app.update(); assert!(!shown(&mut app, ShipPanelField::RepairForm)); @@ -1473,9 +1498,11 @@ fn update_ship_panel_reflects_selection() { app.world_mut().resource_mut::().selected = Some(turret); app.update(); assert!(!shown(&mut app, ShipPanelField::RepairForm)); - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(400_000, [(ItemType::HullPlate, 10)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &items, + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), 10)], + )); app.update(); assert_eq!( app.world().resource::().requested_plates, @@ -1551,15 +1578,18 @@ fn update_ship_panel_shows_no_section_without_a_player_inventory() { fn update_ship_panel_preview_names_the_exact_repair_refusal() { // A section already at full integrity has no repair form; a damaged one // names the exact refusal of its draft in the form summary. + let items = test_items(); let mut app = App::new(); app.add_plugins((MinimalPlugins, AssetPlugin::default())); app.init_asset::(); app.init_resource::(); app.insert_resource(InterfaceIcons::blank()); let (ship, hull, _turret, thruster) = spawn_scripted_ship(app.world_mut()); - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(400_000, [(ItemType::HullPlate, 1)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &items, + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), 1)], + )); app.world_mut() .run_system_once(assign_section_codes) .unwrap(); @@ -1618,6 +1648,7 @@ fn update_ship_panel_hides_the_repair_form_after_a_full_repair() { // section but leaves `requested_plates` untouched; the panel hides the // form, and its field lets go of the keyboard, rather than restate the // outstanding request. + let items = test_items(); let mut app = App::new(); app.add_plugins((MinimalPlugins, AssetPlugin::default())); app.init_asset::(); @@ -1626,9 +1657,11 @@ fn update_ship_panel_hides_the_repair_form_after_a_full_repair() { app.add_message::(); app.add_systems(Update, apply_ship_section_commands); let (ship, hull, _turret, _thruster) = spawn_scripted_ship(app.world_mut()); - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(400_000, [(ItemType::HullPlate, 1)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &items, + 400_000, + [(ItemDesignId::from(ITEM_HULL_PLATE), 1)], + )); app.world_mut() .run_system_once(assign_section_codes) .unwrap(); diff --git a/crates/nova_menu/Cargo.toml b/crates/nova_menu/Cargo.toml index b1f42e58e..47bd5ce4f 100644 --- a/crates/nova_menu/Cargo.toml +++ b/crates/nova_menu/Cargo.toml @@ -48,6 +48,8 @@ bevy_rand = { version = "0.15.0", features = ["wasm_js"] } [dev-dependencies] +# The item catalog fixture the Load list checks saved worlds against. +nova_gameplay = { path = "../nova_gameplay", features = ["serde", "test-support"] } # The leave tests' saving world: the production save systems on a fixture. nova_world_base = { path = "../nova_world_base", features = ["test-support"] } # Scratch worlds folders. diff --git a/crates/nova_menu/src/leave.rs b/crates/nova_menu/src/leave.rs index 43bb33920..b159e9a01 100644 --- a/crates/nova_menu/src/leave.rs +++ b/crates/nova_menu/src/leave.rs @@ -150,12 +150,13 @@ pub(crate) fn drive_pending_leave( let opened = match ( &world.resource::().0, world.get_resource::(), + world.get_resource::(), ) { - (Some(root), Some(packs)) => { - open_world(root, &slug, packs).map_err(|refusal| refusal.to_string()) + (Some(root), Some(packs), Some(items)) => { + open_world(root, &slug, packs, items).map_err(|refusal| refusal.to_string()) } - (None, _) => Err("this system has no folder for saved worlds".to_string()), - (_, None) => Err("no content is loaded".to_string()), + (None, ..) => Err("this system has no folder for saved worlds".to_string()), + (_, None, _) | (_, _, None) => Err("no content is loaded".to_string()), }; match opened { Ok((folder, lock, header, state)) => { diff --git a/crates/nova_menu/src/lib.rs b/crates/nova_menu/src/lib.rs index e2b4730df..c72491573 100644 --- a/crates/nova_menu/src/lib.rs +++ b/crates/nova_menu/src/lib.rs @@ -86,8 +86,8 @@ use ambience::{ }; #[cfg(not(target_arch = "wasm32"))] use load_screen::{ - refresh_load_details, refresh_load_list, refuse_resumed_world, SelectedWorldSlug, - WorldDeleteStep, WorldListings, + poll_world_checks, refresh_load_details, refresh_load_list, refuse_resumed_world, + SelectedWorldSlug, WorldChecks, WorldDeleteStep, WorldListings, }; use menu_ui::{setup_menu_ui, start_new_game_scenario}; use mods::{ @@ -167,6 +167,7 @@ impl Plugin for NovaMenuPlugin { #[cfg(not(target_arch = "wasm32"))] { app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); } @@ -310,12 +311,13 @@ impl Plugin for NovaMenuPlugin { // writes the default/repaired selection, the details pane renders it // in the same frame. Both run off `resource_changed`, not a dirty // function - nothing here has a live registry to watch, only the - // three resources `on_load_screen`, `on_load_world` and the delete - // flow write. + // three resources `on_load_screen`, `on_load_world`, the delete flow + // and `poll_world_checks` write. #[cfg(not(target_arch = "wasm32"))] app.add_systems( Update, ( + poll_world_checks, refresh_load_list.run_if(resource_changed::), refresh_load_details.run_if( resource_changed:: diff --git a/crates/nova_menu/src/load_screen.rs b/crates/nova_menu/src/load_screen.rs index 75054991f..8e54db41d 100644 --- a/crates/nova_menu/src/load_screen.rs +++ b/crates/nova_menu/src/load_screen.rs @@ -1,10 +1,20 @@ //! The Load picker: a two-pane overlay in the Scenarios style listing every //! world folder under [`WorldsRoot`], that resumes the selected one through //! the New Game handoff. +//! +//! The list reads only each world's header. Each world whose header reads is +//! then checked whole off the main thread with [`check_world`]: its state +//! file is read and every item it holds is looked up in the loaded catalog. +//! Until that check ends the row shows "Checking saved state" and Load is +//! greyed; a refused check replaces the row's header with the refusal. Load +//! checks again as it opens the world. + +use std::{collections::HashMap, path::Path, sync::Arc}; use bevy::{ picking::hover::Hovered, prelude::*, + tasks::{block_on, futures_lite::future, IoTaskPool, Task}, ui::InteractionDisabled, ui_widgets::{observe, Activate, Button}, }; @@ -15,7 +25,7 @@ use nova_ui::{ widget::{list_row, themed_button, ButtonVariant, Selected, ThemedText, UiText}, }; use nova_world_base::prelude::{ - delete_world, list_worlds, open_world, resume_world, WorldListing, WorldRefusal, + check_world, delete_world, list_worlds, open_world, resume_world, WorldListing, WorldRefusal, WorldResumeRefused, WorldSaveSession, }; @@ -37,6 +47,22 @@ impl Default for WorldListings { } } +/// The state checks still running, by world slug, for the worlds of +/// [`WorldListings`] whose header read. Replaced on every re-read of the +/// list; dropping a task cancels it. +#[derive(Resource, Default)] +pub(crate) struct WorldChecks(pub(crate) HashMap>>); + +impl WorldChecks { + /// Whether the world `slug` is still being checked. + fn pending(&self, slug: &str) -> bool { + self.0.contains_key(slug) + } +} + +/// The text a row and the details pane show while the world is checked. +const CHECKING_TEXT: &str = "Checking saved state"; + /// The slug the details pane renders. `None` until the list populates; /// `refresh_load_list` default-selects the first row, `on_load_world_row_select` /// sets it from a row click. @@ -81,19 +107,76 @@ pub(crate) struct LoadWorldDetails; #[derive(Component)] pub(crate) struct LoadWorldButton; +/// Read the worlds under `root` and start a [`check_world`] task for each +/// world whose header read, replacing every check still running. +fn read_listings( + root: &Path, + packs: &LoadedSectionPacks, + items: &GameItems, + checks: &mut WorldChecks, +) -> Result, WorldRefusal> { + let listings = list_worlds(root, packs); + let packs = Arc::new(packs.clone()); + let items = Arc::new(items.clone()); + checks.0 = listings + .iter() + .flatten() + .filter(|listing| listing.header.is_ok()) + .map(|listing| { + let (root, slug) = (root.to_path_buf(), listing.folder.slug.clone()); + let (packs, items) = (Arc::clone(&packs), Arc::clone(&items)); + let task = + IoTaskPool::get().spawn(async move { check_world(&root, &slug, &packs, &items) }); + (listing.folder.slug.clone(), task) + }) + .collect(); + listings +} + +/// Take every finished world check: a refusal replaces its row's header, +/// and either end redraws the list. +pub(crate) fn poll_world_checks( + mut checks: ResMut, + mut listings: ResMut, +) { + let mut finished = Vec::new(); + checks.0.retain(|slug, task| { + let Some(result) = block_on(future::poll_once(task)) else { + return true; + }; + finished.push((slug.clone(), result)); + false + }); + if finished.is_empty() { + return; + } + if let Ok(rows) = &mut listings.0 { + for (slug, result) in finished { + if let (Err(refusal), Some(row)) = + (result, rows.iter_mut().find(|row| row.folder.slug == slug)) + { + row.header = Err(refusal); + } + } + } + listings.set_changed(); +} + /// Open the Load panel: re-read the worlds root and show the panel the way /// `on_scenarios` does. pub(crate) fn on_load_screen( _activate: On, root: Res, packs: Res, + items: Res, + mut checks: ResMut, mut listings: ResMut, mut selected: ResMut, mut step: ResMut, mut panel: Single<&mut Visibility, With>, ) { listings.0 = match root.0.as_deref() { - Some(root) => list_worlds(root, &packs), + Some(root) => read_listings(root, &packs, &items, &mut checks), None => Ok(Vec::new()), }; selected.0 = None; @@ -158,6 +241,8 @@ pub(crate) fn on_delete_world_confirm( _activate: On, root: Res, packs: Res, + items: Res, + mut checks: ResMut, mut step: ResMut, mut listings: ResMut, ) { @@ -169,7 +254,7 @@ pub(crate) fn on_delete_world_confirm( }; let slug = slug.clone(); let result = delete_world(root, &slug); - listings.0 = list_worlds(root, &packs); + listings.0 = read_listings(root, &packs, &items, &mut checks); *step = match result { Ok(()) => WorldDeleteStep::Idle, Err(refusal) => WorldDeleteStep::Refused { @@ -179,15 +264,17 @@ pub(crate) fn on_delete_world_confirm( }; } -/// Load the selected world. On `Err` (a world locked or refused since the -/// list was read), the refusal replaces that row's header so the list and -/// details redraw with it; nothing starts. On `Ok`, resume the world and hand -/// off to Playing exactly like New Game. +/// Load the selected world, checked again as it opens. On `Err` (a world +/// locked or refused since the list was read), the refusal replaces that +/// row's header so the list and details redraw with it; nothing starts. On +/// `Ok`, resume the world and hand off to Playing exactly like New Game. pub(crate) fn on_load_world( _activate: On, mut commands: Commands, root: Res, packs: Res, + items: Res, + checks: Res, selected: Res, mut listings: ResMut, mut step: ResMut, @@ -198,10 +285,13 @@ pub(crate) fn on_load_world( let (Some(root), Some(slug)) = (root.0.as_deref(), selected.0.as_deref()) else { return; }; + if checks.pending(slug) { + return; + } // A Load drops a Delete asked about, so a refused resume does not bring // its prompt back. *step = WorldDeleteStep::Idle; - match open_world(root, slug, &packs) { + match open_world(root, slug, &packs, &items) { Ok((folder, lock, header, world_state)) => { commands.queue(move |world: &mut World| { resume_world(world, folder, lock, &header, world_state); @@ -236,6 +326,8 @@ pub(crate) fn refuse_resumed_world( refused: Res, root: Res, packs: Res, + items: Res, + mut checks: ResMut, mut listings: ResMut, mut state: ResMut>, mut pause: ResMut>, @@ -248,9 +340,11 @@ pub(crate) fn refuse_resumed_world( return; }; listings.0 = match root.0.as_deref() { - Some(root) => list_worlds(root, &packs), + Some(root) => read_listings(root, &packs, &items, &mut checks), None => Ok(Vec::new()), }; + // The refusal stands over whatever the new check of the world finds. + checks.0.remove(&refused.slug); if let Ok(rows) = &mut listings.0 { match rows.iter_mut().find(|row| row.folder.slug == refused.slug) { Some(row) => row.header = Err(WorldRefusal::Unrestored(refused.reason.clone())), @@ -272,6 +366,7 @@ pub(crate) fn refresh_load_list( mut commands: Commands, root: Res, listings: Res, + checks: Res, mut selected: ResMut, lists: Query>, ) { @@ -314,7 +409,8 @@ pub(crate) fn refresh_load_list( commands.entity(list).with_children(|list| { for row in rows { let is_selected = selected.0.as_deref() == Some(row.folder.slug.as_str()); - spawn_load_row(list, row, is_selected); + let checking = checks.pending(&row.folder.slug); + spawn_load_row(list, row, is_selected, checking); } }); } @@ -337,8 +433,14 @@ fn spawn_load_note(commands: &mut Commands, list: Entity, text: &str) { } /// Spawn one clickable world row: its name (or slug, when the header did not -/// read) over the refusal text, when it has one. -fn spawn_load_row(list: &mut ChildSpawnerCommands, listing: &WorldListing, selected: bool) { +/// read) over the refusal text, when it has one, or [`CHECKING_TEXT`] while +/// it is `checking`. +fn spawn_load_row( + list: &mut ChildSpawnerCommands, + listing: &WorldListing, + selected: bool, + checking: bool, +) { let title = match &listing.header { Ok(header) => header.name.clone(), Err(_) => listing.folder.slug.clone(), @@ -368,6 +470,19 @@ fn spawn_load_row(list: &mut ChildSpawnerCommands, listing: &WorldListing, selec TextColor(Color::NONE), ThemedText::new(UiColor::Body), )); + if checking { + row.spawn(( + Name::new("Load World Row Checking"), + UiText, + Text::new(CHECKING_TEXT), + TextFont { + font_size: FontSize::Px(12.0), + ..default() + }, + TextColor(Color::NONE), + ThemedText::new(UiColor::Label), + )); + } if let Err(refusal) = &listing.header { row.spawn(( Name::new("Load World Row Refusal"), @@ -386,11 +501,12 @@ fn spawn_load_row(list: &mut ChildSpawnerCommands, listing: &WorldListing, selec /// Rebuild the world details pane from the selected world: name, seed, /// sector, credits, game version and saved time, or the refusal, and a Load -/// button greyed on a refusal, and a Delete button (or its confirm prompt, or -/// the reason a delete refused). +/// button greyed on a refusal or while the world is checked, and a Delete +/// button (or its confirm prompt, or the reason a delete refused). pub(crate) fn refresh_load_details( mut commands: Commands, listings: Res, + checks: Res, selected: Res, step: Res, panels: Query>, @@ -471,12 +587,25 @@ pub(crate) fn refresh_load_details( ThemedText::new(UiColor::Body), )); } - details.spawn(( + let mut load = details.spawn(( Name::new("Load World Button"), themed_button("Load"), LoadWorldButton, observe(on_load_world), )); + if checks.pending(&listing.folder.slug) { + load.insert(InteractionDisabled); + details.spawn(( + Name::new("Load World Details Checking"), + Text::new(CHECKING_TEXT), + TextFont { + font_size: FontSize::Px(13.0), + ..default() + }, + TextColor(Color::NONE), + ThemedText::new(UiColor::Label), + )); + } } Err(refusal) => { details.spawn(( diff --git a/crates/nova_menu/src/tests/leave.rs b/crates/nova_menu/src/tests/leave.rs index e9b3a6312..aba604c39 100644 --- a/crates/nova_menu/src/tests/leave.rs +++ b/crates/nova_menu/src/tests/leave.rs @@ -180,6 +180,7 @@ fn leaving_a_saved_world_waits_for_the_leave_save_with_input_still_paused() { "leave", app.world() .resource::(), + app.world().resource::(), ) .unwrap(); let [FrozenTransient { @@ -422,6 +423,7 @@ fn entering_the_menu_after_a_death_releases_the_world() { "leave", app.world() .resource::(), + app.world().resource::(), ) .expect("the world opens again"); assert_eq!(on_disk(&app, root.path()), (1, 300)); @@ -494,6 +496,7 @@ fn a_write_in_flight_keeps_the_world_locked_after_a_death_exit() { "leave", app.world() .resource::(), + app.world().resource::(), ) }; assert!( diff --git a/crates/nova_menu/src/tests/load_screen.rs b/crates/nova_menu/src/tests/load_screen.rs index 628bd589b..d8fd66b89 100644 --- a/crates/nova_menu/src/tests/load_screen.rs +++ b/crates/nova_menu/src/tests/load_screen.rs @@ -1,16 +1,22 @@ //! The Load picker: a saved world lists and loads, a refused one lists and //! refuses to load. -use bevy::{prelude::*, ui::InteractionDisabled, ui_widgets::Activate}; +use std::{future, thread, time::Duration}; + +use bevy::{prelude::*, tasks::IoTaskPool, ui::InteractionDisabled, ui_widgets::Activate}; use nova_assets::prelude::{ContentCatalogDigest, LoadedSectionPacks}; use nova_gameplay::prelude::*; use nova_world_base::{ - prelude::{create_world, ResumedWorld, WorldSaveSession, WorldSaveStatus}, + prelude::{create_world, ResumedWorld, WorldRefusal, WorldSaveSession, WorldSaveStatus}, test_support::{arm_save_fixture, WorldSaveTestPlugin}, }; use super::support::{app, dummy_scenarios}; -use crate::{load_screen::SelectedWorldSlug, scenarios::NewGameScenario, world_setup::WorldsRoot}; +use crate::{ + load_screen::{SelectedWorldSlug, WorldChecks}, + scenarios::NewGameScenario, + world_setup::WorldsRoot, +}; /// A menu app entered the real way, with the Load picker's plugin wiring in /// place. @@ -62,6 +68,36 @@ fn save_until_idle(app: &mut App) { panic!("the fixture world did not finish saving in time"); } +/// Run `app` until [`WorldChecks`] empties, or panic after a generous number +/// of frames - the checks run off the main thread on the `IoTaskPool`, so +/// more than one frame can pass before they land. +fn checks_settle(app: &mut App) { + for _ in 0..200 { + app.update(); + if app.world().resource::().0.is_empty() { + return; + } + thread::sleep(Duration::from_millis(5)); + } + panic!("the world checks did not settle in time"); +} + +/// Whether any entity in `app` is named `name`. +fn has_named(app: &mut App, name: &str) -> bool { + let mut names = app.world_mut().query::<&Name>(); + names.iter(app.world()).any(|found| found.as_str() == name) +} + +/// The text of the one [`Text`] entity named `name`. +fn text_of(app: &mut App, name: &str) -> String { + let entity = named(app, name); + app.world() + .get::(entity) + .expect("a Text component") + .0 + .clone() +} + #[test] fn load_lists_a_saved_world_and_a_refused_one_and_loads_only_the_saved_one() { let mut app = menu(); @@ -93,6 +129,7 @@ fn load_lists_a_saved_world_and_a_refused_one_and_loads_only_the_saved_one() { drop(bad_lock); press(&mut app, "Load Button"); + checks_settle(&mut app); let good_row = named(&mut app, "Load World Row: good-world"); let bad_row = named(&mut app, "Load World Row: bad-world"); @@ -131,6 +168,154 @@ fn load_lists_a_saved_world_and_a_refused_one_and_loads_only_the_saved_one() { assert_eq!(state(&app), GameStates::Playing); } +/// Load greys a world while its saved state is checked, then refuses one +/// whose state holds an item the loaded catalog lacks, with the reason, and +/// never starts it. +#[test] +fn load_greys_a_world_while_its_check_runs_and_refuses_one_holding_an_unknown_item() { + let mut app = menu(); + let root = tempfile::tempdir().expect("a scratch worlds root"); + app.insert_resource(WorldsRoot(Some(root.path().to_path_buf()))); + + app.add_plugins(WorldSaveTestPlugin); + let player = arm_save_fixture(app.world_mut()); + // The fixture player holds no stock; give it one item so the loaded + // catalog can go on to lack it. + let hull_plate = ItemDesignId::from(ITEM_HULL_PLATE); + let items = app.world().resource::().clone(); + let mut inventory = ShipInventory::new(&items, 10_000, std::iter::empty()); + inventory.add(&items, &hull_plate, 1); + app.world_mut().entity_mut(player).insert(inventory); + + let (folder, lock) = + create_world(root.path(), "Holder World").expect("an empty root takes a new world"); + app.world_mut().insert_resource(WorldSaveSession::created( + folder, + lock, + "Holder World".to_string(), + 42, + )); + save_until_idle(&mut app); + app.world_mut().remove_resource::(); + + // The catalog the Load list checks against now lacks HullPlate. + app.insert_resource(GameItems::new( + nova_gameplay::test_support::test_items() + .iter() + .filter(|design| design.id != hull_plate) + .cloned(), + )); + + // Part A: a check still running greys the row and the Load button, and + // Load does nothing while it is pending. The override replaces the real + // check BEFORE any `app.update()` runs, so it cannot race `poll_world_checks` + // finding the real one first. + let load_screen_button = named(&mut app, "Load Button"); + app.world_mut().trigger(Activate { + entity: load_screen_button, + }); + app.world_mut().resource_mut::().0.insert( + "holder-world".to_string(), + IoTaskPool::get().spawn(future::pending::>()), + ); + app.update(); + let row = named(&mut app, "Load World Row: holder-world"); + app.world_mut().trigger(Activate { entity: row }); + app.update(); + assert!( + has_named(&mut app, "Load World Row Checking"), + "a world still being checked shows the Checking label on its row" + ); + let load_button = named(&mut app, "Load World Button"); + assert!( + app.world() + .entity(load_button) + .contains::(), + "a world still being checked greys the Load button" + ); + press(&mut app, "Load World Button"); + assert_eq!( + state(&app), + GameStates::MainMenu, + "Load does nothing while the check is pending" + ); + assert!( + app.world().get_resource::().is_none(), + "Load does not start a world whose check is still pending" + ); + + // Part B: the real check lands and refuses the unknown item; the row and + // details show the reason and Load stays greyed. + press(&mut app, "Load Button"); + checks_settle(&mut app); + let row = named(&mut app, "Load World Row: holder-world"); + app.world_mut().trigger(Activate { entity: row }); + app.update(); + let reason = "unknown item 'HullPlate' held by the player ship"; + assert!( + text_of(&mut app, "Load World Row Refusal").contains(reason), + "the row names the unknown item" + ); + assert!( + text_of(&mut app, "Load World Details Refusal").contains(reason), + "the details pane names the unknown item" + ); + let load_button = named(&mut app, "Load World Button"); + assert!( + app.world() + .entity(load_button) + .contains::(), + "a refused world's Load button stays greyed" + ); + assert!( + !has_named(&mut app, "Load World Row Checking"), + "a settled check shows no Checking label" + ); + + // Part C: the catalog has the item again, so Load is no longer refused. + app.insert_resource(nova_gameplay::test_support::test_items()); + press(&mut app, "Load Button"); + checks_settle(&mut app); + let row = named(&mut app, "Load World Row: holder-world"); + app.world_mut().trigger(Activate { entity: row }); + app.update(); + let load_button = named(&mut app, "Load World Button"); + assert!( + !app.world() + .entity(load_button) + .contains::(), + "once the catalog has the item again, Load is not disabled" + ); + + // Part D: the catalog loses the item again after the list settled. Load + // checks again as it opens, so the stale enabled row does not start the + // world and shows the refusal instead. + app.insert_resource(GameItems::new( + nova_gameplay::test_support::test_items() + .iter() + .filter(|design| design.id != hull_plate) + .cloned(), + )); + press(&mut app, "Load World Button"); + assert_eq!( + state(&app), + GameStates::MainMenu, + "Load refuses a world whose item left the catalog after the check" + ); + assert!( + app.world().get_resource::().is_none(), + "Load does not start a world whose item left the catalog after the check" + ); + assert!( + text_of(&mut app, "Load World Row Refusal").contains(reason), + "the row names the unknown item after a refused Load" + ); + assert!( + text_of(&mut app, "Load World Details Refusal").contains(reason), + "the details pane names the unknown item after a refused Load" + ); +} + /// Delete asks to confirm first, and only removes the world and its row on confirm. #[test] fn delete_asks_first_then_removes_the_world_and_its_row() { diff --git a/crates/nova_menu/src/tests/support.rs b/crates/nova_menu/src/tests/support.rs index a29f31029..fc7bd1bd2 100644 --- a/crates/nova_menu/src/tests/support.rs +++ b/crates/nova_menu/src/tests/support.rs @@ -109,6 +109,9 @@ pub(crate) fn app_storing_settings_at(root: impl Into) -> Ap // merges no content, and an empty registry would draw a picker with no // rows at all. app.insert_resource(GameUiThemes(base_ui_themes())); + // The item catalog the Load list checks each saved world against; the + // content merge publishes it in production. + app.insert_resource(nova_gameplay::test_support::test_items()); // Headless: no TimePlugin, so provide the clocks the pause systems and the // handbook's looping demonstrations touch. Nothing advances them, so a test // that wants time to pass moves the clock itself. diff --git a/crates/nova_modding/src/lib.rs b/crates/nova_modding/src/lib.rs index 12776ef92..f2c83a449 100644 --- a/crates/nova_modding/src/lib.rs +++ b/crates/nova_modding/src/lib.rs @@ -45,6 +45,7 @@ use bevy::{ prelude::*, reflect::TypePath, }; +use nova_gameplay::prelude::ItemDesign; // The pure serde format types live in the engine-free `nova_mod_format` // crate so the portal generator builds without bevy; re-exported here so game // code keeps importing them from nova_modding. @@ -121,6 +122,15 @@ pub enum Content { /// would size every `Content` in every content file by it. `Box` /// serializes exactly like `T`, so the RON wire shape is unchanged. UiTheme(Box), + /// An [`ItemDesign`] - registers into `GameItems` keyed by its id. What a + /// ship can carry, trade and drop: its name, category, mass and prices. + /// + /// A mod adds an item by declaring a new id. Only a mod that depends on + /// the item's owner may declare its id again, and then replaces the whole + /// item; two packs that declare one id without that are both refused + /// (`nova_assets::items`). Stock, cargo and saves name the id, so an id + /// is a promise. + Item(ItemDesign), } impl Content { @@ -139,6 +149,7 @@ impl Content { Content::Ship(_) => "ship", Content::Lesson(_) => "lesson", Content::UiTheme(_) => "ui theme", + Content::Item(_) => "item", } } @@ -153,6 +164,7 @@ impl Content { Content::Ship(cfg) => cfg.id.as_str(), Content::Lesson(lesson) => &lesson.id, Content::UiTheme(cfg) => &cfg.id, + Content::Item(design) => design.id.as_str(), } } diff --git a/crates/nova_probe/Cargo.toml b/crates/nova_probe/Cargo.toml index f3b9e2518..47fe687fa 100644 --- a/crates/nova_probe/Cargo.toml +++ b/crates/nova_probe/Cargo.toml @@ -56,5 +56,10 @@ serde_json = { version = "1" } [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { version = "0.3", features = ["Window", "Location"] } +[dev-dependencies] +# `test_items`: the snapshot tests build ShipInventory and CargoCanister +# fixtures against the fixture catalog rather than a merged mod's. +nova_gameplay = { path = "../nova_gameplay", features = ["test-support"] } + [lints] workspace = true diff --git a/crates/nova_probe/src/capabilities/snapshot.rs b/crates/nova_probe/src/capabilities/snapshot.rs index 7d6ff93f6..198bfb982 100644 --- a/crates/nova_probe/src/capabilities/snapshot.rs +++ b/crates/nova_probe/src/capabilities/snapshot.rs @@ -25,8 +25,8 @@ //! carrying no component is the all-enabled default. //! - `ships[].cargo` - the hold and the purse: `capacity_g`, `used_g`, //! `free_g` (grams), `credits`, and `items` as `{item, count}` in -//! [`ItemType`](nova_gameplay::prelude::ItemType) order, empty when the hold -//! is. +//! [`ItemDesignId`](nova_gameplay::prelude::ItemDesignId) order, empty when +//! the hold is. //! - `ships[].docking` - whether a clamp holds the hull and to whom, and the //! approach: the nearest pair of free ports between the ship and its travel //! lock, measured the way the docking sight draws it and graded against the @@ -133,7 +133,7 @@ use nova_events::prelude::{EntityId, EntityTypeName}; use nova_gameplay::{ prelude::{ Allegiance, BeaconLabel, BeaconMarker, CargoCanister, CargoCanisterRuntimeId, - DefeatedMarker, DominantWell, GameObjectives, Health, HealthZeroMarker, + DefeatedMarker, DominantWell, GameItems, GameObjectives, Health, HealthZeroMarker, IntegrityDisabledMarker, NeutralizedMarker, ProjectileDamage, ProjectileOwner, RunCheats, SectionClass, SectionMarker, ShipCredits, ShipInventory, SpaceshipRootMarker, TempEntity, TempEntityState, TorpedoProjectileMarker, TurretBulletProjectileMarker, @@ -1041,7 +1041,7 @@ fn canister_record(world: &World, entity: Entity) -> (u64, serde_json::Value) { "stacks": canister .stacks() .map(|(item, count)| { - serde_json::json!({ "item": format!("{item:?}"), "count": count }) + serde_json::json!({ "item": item.as_str(), "count": count }) }) .collect::>(), }); @@ -1064,6 +1064,7 @@ fn ship_record( let credits = world .get::(entity) .expect("a ship root carries ShipCredits"); + let items = world.resource::(); let skin = skin_index(world, entity); let sections = ordered( world @@ -1100,13 +1101,13 @@ fn ship_record( "capabilities": capabilities(world, entity), "cargo": { "capacity_g": inventory.capacity_g(), - "used_g": inventory.used_g(), - "free_g": inventory.free_g(), + "used_g": inventory.used_g(items), + "free_g": inventory.free_g(items), "credits": credits.0, "items": inventory .stacks() .map(|(item, count)| { - serde_json::json!({ "item": format!("{item:?}"), "count": count }) + serde_json::json!({ "item": item.as_str(), "count": count }) }) .collect::>(), }, @@ -1634,7 +1635,10 @@ fn ordnance_record(world: &World, entity: Entity) -> (String, serde_json::Value) mod tests { use std::sync::atomic::{AtomicU32, Ordering}; - use nova_gameplay::prelude::{DamageType, ItemType}; + use nova_gameplay::{ + prelude::{DamageType, ITEM_HULL_PLATE, ITEM_IRON_ORE, ITEM_PDC_ROUND}, + test_support::test_items, + }; use nova_ship::prelude::{unit_cube_link_points, SectionReloadConfig}; use super::*; @@ -1653,6 +1657,7 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.init_resource::(); + app.insert_resource(test_items()); app } @@ -1682,7 +1687,7 @@ mod tests { delay: 4.0, amount: 5, }, - ItemType::PdcRound, + ITEM_PDC_ROUND.into(), ); reload.elapsed = 1.5; let section = app @@ -1860,9 +1865,10 @@ mod tests { #[test] fn canister_snapshots_order_same_content_by_numeric_runtime_id() { fn spawn_canisters(app: &mut App, ids: [u64; 2]) { + let items = test_items(); for id in ids { - let mut canister = CargoCanister::new(ItemType::IronOre, 2); - canister.add(ItemType::HullPlate, 1); + let mut canister = CargoCanister::new(&items, &ITEM_IRON_ORE.into(), 2); + canister.add(&items, &ITEM_HULL_PLATE.into(), 1); app.world_mut().spawn(( canister, CargoCanisterRuntimeId(id), @@ -1913,7 +1919,7 @@ mod tests { fn a_live_canister_without_runtime_id_fails_snapshot_capture() { let mut app = rig(); app.world_mut() - .spawn(CargoCanister::new(ItemType::IronOre, 1)); + .spawn(CargoCanister::new(&test_items(), &ITEM_IRON_ORE.into(), 1)); capture_snapshot(app.world_mut(), "test"); } @@ -1923,7 +1929,7 @@ mod tests { let mut app = rig(); for _ in 0..2 { app.world_mut().spawn(( - CargoCanister::new(ItemType::IronOre, 1), + CargoCanister::new(&test_items(), &ITEM_IRON_ORE.into(), 1), CargoCanisterRuntimeId(7), Transform::IDENTITY, LinearVelocity(Vec3::ZERO), @@ -1982,9 +1988,14 @@ mod tests { #[test] fn the_cargo_record_follows_the_hold_and_the_purse_through_a_change() { let mut app = rig(); + let items = test_items(); let player = ship(&mut app, "player", Vec3::ZERO); app.world_mut().entity_mut(player).insert(( - ShipInventory::new(100_000, [(ItemType::IronOre, 3), (ItemType::HullPlate, 2)]), + ShipInventory::new( + &items, + 100_000, + [(ITEM_IRON_ORE.into(), 3), (ITEM_HULL_PLATE.into(), 2)], + ), ShipCredits(250), )); app.update(); @@ -2005,8 +2016,8 @@ mod tests { let mut entity = app.world_mut().entity_mut(player); let mut inventory = entity.get_mut::().unwrap(); - inventory.remove(ItemType::HullPlate, 2); - inventory.add(ItemType::PdcRound, 5); + inventory.remove(&ITEM_HULL_PLATE.into(), 2); + inventory.add(&items, &ITEM_PDC_ROUND.into(), 5); entity.get_mut::().unwrap().0 = 330; app.update(); @@ -2018,11 +2029,11 @@ mod tests { "free_g": 69_000, "credits": 330, "items": [ - { "item": "PdcRound", "count": 5 }, { "item": "IronOre", "count": 3 }, + { "item": "PdcRound", "count": 5 }, ], }), - "an emptied stack leaves the list and items keep ItemType order", + "an emptied stack leaves the list and items keep id order", ); } diff --git a/crates/nova_scenario/src/actions/ship.rs b/crates/nova_scenario/src/actions/ship.rs index f1b6af7fa..d515f5507 100644 --- a/crates/nova_scenario/src/actions/ship.rs +++ b/crates/nova_scenario/src/actions/ship.rs @@ -1937,7 +1937,7 @@ pub fn apply_infinite_ammo(world: &mut World, section: Entity, enabled: bool) -> // state the caller asked for. return false; }; - let reload = entity.get::().copied(); + let reload = entity.get::().cloned(); entity.insert(SuspendedSectionAmmo { capacity: ammo.capacity, reload, @@ -1945,7 +1945,7 @@ pub fn apply_infinite_ammo(world: &mut World, section: Entity, enabled: bool) -> entity.remove::(); entity.remove::(); } else { - let Some(suspended) = entity.get::().copied() else { + let Some(suspended) = entity.get::().cloned() else { return false; }; entity.insert(SectionAmmo::new(suspended.capacity)); diff --git a/crates/nova_scenario/src/actions/spawn.rs b/crates/nova_scenario/src/actions/spawn.rs index cee06dafc..f0edabf93 100644 --- a/crates/nova_scenario/src/actions/spawn.rs +++ b/crates/nova_scenario/src/actions/spawn.rs @@ -812,8 +812,10 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_plugins(crate::objects::spaceship::SpaceshipPlugin); + assert_eq!(app.world().resource::().iter().count(), 0); app.init_resource::(); app.init_resource::(); + app.insert_resource(nova_gameplay::test_support::test_items()); let config = ScenarioObjectConfig { base: BaseScenarioObjectConfig { @@ -871,7 +873,7 @@ mod tests { /// reads back from text first. #[test] fn a_resumed_player_spawn_thaws_the_saved_ship() { - use nova_gameplay::test_support::settle; + use nova_gameplay::test_support::{settle, test_items}; use nova_ship::prelude::{ BaseSectionConfig, ControllerSectionConfig, ControllerSectionRotationInput, GameSections, HullSectionConfig, NovaFlightPlugin, PDControllerPlugin, SectionConfig, @@ -905,6 +907,7 @@ mod tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.insert_resource(test_items()); app.finish(); app } @@ -977,10 +980,12 @@ mod tests { health.current = health.max * 0.4; } } - world - .get_mut::(ship) - .unwrap() - .add(ItemType::IronOre, 3); + let items = world.resource::().clone(); + world.get_mut::(ship).unwrap().add( + &items, + &ItemDesignId::from(ITEM_IRON_ORE), + 3, + ); world.entity_mut(ship).insert(ShipCredits(555)); settle(&mut played); let world = played.world_mut(); diff --git a/crates/nova_scenario/src/lint/scenario.rs b/crates/nova_scenario/src/lint/scenario.rs index 13731a45d..fee1fa4ce 100644 --- a/crates/nova_scenario/src/lint/scenario.rs +++ b/crates/nova_scenario/src/lint/scenario.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use bevy::prelude::Alpha; -use nova_gameplay::prelude::SectionClass; +use nova_gameplay::prelude::{GameItems, SectionClass}; use nova_input::prelude::InputSource; use super::{ship::check_object_prototypes, KnownSections, KnownShipDesigns, LintIssue}; @@ -37,6 +37,7 @@ struct Catalog<'a> { sections: &'a KnownSections, ships: &'a KnownShipDesigns, scenarios: &'a HashSet, + items: &'a GameItems, } /// What the lint knows about one ship a spawn declares. @@ -109,18 +110,21 @@ pub fn lint_campaign( /// Lint one scenario against the identifier sets the caller knows about: /// `sections` (the section-prototype catalog visible to this scenario's /// bundle), `ships` (the ship catalog it may spawn by id), `known_scenarios` -/// and `known_scenarios` (every scenario id a `NextScenario` may target) - all -/// of them normally base + all installed bundles. +/// (every scenario id a `NextScenario` may target) and `items` (every item a +/// ship's stock may name) - all of them normally base + all installed +/// bundles. pub fn lint_scenario( scenario: &ScenarioConfig, sections: &KnownSections, ships: &KnownShipDesigns, known_scenarios: &HashSet, + items: &GameItems, ) -> Vec { let catalog = Catalog { sections, ships, scenarios: known_scenarios, + items, }; let id = scenario.id.as_str(); let mut issues = Vec::new(); @@ -770,7 +774,14 @@ fn check_action( check_object_names(action, scenario, satisfiable, issues); match action { EventActionConfig::SpawnScenarioObject(config) => { - check_object_prototypes(config, scenario, catalog.sections, catalog.ships, issues); + check_object_prototypes( + config, + scenario, + catalog.sections, + catalog.ships, + catalog.items, + issues, + ); check_spawned_arrival_standoff(config, scenario, issues); check_spawned_patrol_stops(config, scenario, issues); check_asteroid_kind(config, scenario, issues); @@ -785,6 +796,7 @@ fn check_action( scenario, catalog.sections, catalog.ships, + catalog.items, issues, ); check_spawned_arrival_standoff(&config.template, scenario, issues); @@ -1812,7 +1824,13 @@ mod tests { key: String::new(), })], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert_eq!( issues .iter() @@ -1841,7 +1859,13 @@ mod tests { vec![EventActionConfig::CinematicTitle(card(seconds))], Vec::new(), ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( issues.iter().any(|issue| { issue.severity == LintSeverity::Error @@ -1855,7 +1879,13 @@ mod tests { vec![EventActionConfig::CinematicTitle(card(8.0))], Vec::new(), ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( !issues .iter() @@ -1882,7 +1912,13 @@ mod tests { key: "oribt_hold".to_string(), })], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( issues.iter().any(|issue| { issue.severity == LintSeverity::Error @@ -2055,6 +2091,7 @@ mod tests { §ions(&["known_proto"]), &ships(&[]), &known(&["test_scenario", "next_chapter"]), + &GameItems::default(), ); assert!(issues.is_empty(), "clean scenario flagged: {issues:?}"); } @@ -2083,7 +2120,13 @@ mod tests { vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); @@ -2103,7 +2146,13 @@ mod tests { })], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); assert!(errs[0].message.contains("gone")); @@ -2130,7 +2179,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 2, "{issues:?}"); assert!(errs[0].message.contains("SetInfiniteAmmo")); @@ -2157,7 +2212,13 @@ mod tests { )], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 2, "{issues:?}"); assert!(errs[0].message.contains("`anchor`")); @@ -2182,7 +2243,13 @@ mod tests { )], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); assert!(errs[0].message.contains("ghost")); @@ -2202,6 +2269,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); @@ -2224,6 +2292,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!(errors(&issues).is_empty(), "{issues:?}"); @@ -2234,6 +2303,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!(errors(&issues).is_empty(), "{issues:?}"); } @@ -2250,6 +2320,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); errors(&issues) .iter() @@ -2321,7 +2392,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); errors(&issues) .into_iter() .filter(|issue| issue.message.contains("StopShip")) @@ -2346,8 +2423,11 @@ mod tests { /// fails lint, to the gram. #[test] fn inventory_stock_heavier_than_the_hold_fails_lint() { - use nova_gameplay::prelude::ItemType; - let overstock = |item, count| { + use nova_gameplay::{ + prelude::{ItemDesignId, ITEM_HULL_PLATE, ITEM_PDC_ROUND}, + test_support::test_items, + }; + let overstock = |item: &str, count| { let mut spawn = spawn_armed_ship( "warship", SpaceshipController::AI(AIControllerConfig::default()), @@ -2359,9 +2439,18 @@ mod tests { else { unreachable!("spawn_armed_ship spawns a spaceship"); }; - ship.inventory = nova_gameplay::prelude::ShipInventoryStock::new([(item, count)]); + ship.inventory = nova_gameplay::prelude::ShipInventoryStock::new([( + ItemDesignId::from(item), + count, + )]); let s = scenario(vec![spawn], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &test_items(), + ); errors(&issues) .into_iter() .filter(|issue| issue.message.contains("inventory stock")) @@ -2370,20 +2459,58 @@ mod tests { }; assert!( - overstock(ItemType::HullPlate, 10).is_empty(), + overstock(ITEM_HULL_PLATE, 10).is_empty(), "100 kg fits a 100 kg hold" ); assert_eq!( - overstock(ItemType::HullPlate, 11), + overstock(ITEM_HULL_PLATE, 11), ["ship 'warship': inventory stock of 110 kg exceeds its 100 kg hold"] ); - assert!(overstock(ItemType::PdcRound, 500).is_empty()); + assert!(overstock(ITEM_PDC_ROUND, 500).is_empty()); assert_eq!( - overstock(ItemType::PdcRound, 501), + overstock(ITEM_PDC_ROUND, 501), ["ship 'warship': inventory stock of 100.2 kg exceeds its 100 kg hold"] ); } + /// Stock that names an item the catalog lacks fails lint with the item, + /// once, and the unknown stock is not weighed against the hold. + #[test] + fn a_ship_stocking_an_item_the_catalog_lacks_fails_lint() { + use nova_gameplay::{ + prelude::{ItemDesignId, ShipInventoryStock, ITEM_HULL_PLATE}, + test_support::test_items, + }; + let mut spawn = spawn_armed_ship( + "warship", + SpaceshipController::AI(AIControllerConfig::default()), + ); + let EventActionConfig::SpawnScenarioObject(ScenarioObjectConfig { + kind: ScenarioObjectKind::Spaceship(ship), + .. + }) = &mut spawn + else { + unreachable!("spawn_armed_ship spawns a spaceship"); + }; + ship.inventory = ShipInventoryStock::new([ + (ItemDesignId::from(ITEM_HULL_PLATE), 1), + (ItemDesignId::from("lost_core"), 50), + ]); + let s = scenario(vec![spawn], vec![]); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &test_items(), + ); + let messages: Vec<&str> = errors(&issues) + .into_iter() + .map(|issue| issue.message.as_str()) + .collect(); + assert_eq!(messages, ["ship 'warship': unknown item 'lost_core'"]); + } + /// A forced SHOT is stricter than a helm order and keeps refusing an AI /// ship: the shot leaves down whatever line the hull holds, and an AI hull /// rewrites its own aim every frame. @@ -2403,7 +2530,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let refusals: Vec<_> = errors(&issues) .into_iter() .filter(|issue| { @@ -2430,7 +2563,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let refusals: Vec<_> = errors(&issues) .into_iter() .filter(|issue| issue.message.contains("SetAIEngageRange")) @@ -2457,7 +2596,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!( errors(&issues) .iter() @@ -2492,7 +2637,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!(errors(&issues).is_empty(), "{issues:?}"); } @@ -2521,7 +2672,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert!( errs.iter() @@ -2570,7 +2727,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); assert!( @@ -2632,7 +2795,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); for id in [ "no_size", @@ -2677,7 +2846,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!(errors(&issues).is_empty(), "{issues:?}"); } @@ -2705,7 +2880,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 3, "{issues:?}"); assert!( @@ -2783,7 +2964,13 @@ mod tests { }; let errors_of = |action| { let s = scenario(vec![action], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); errors(&issues) .into_iter() .map(|issue| issue.message.clone()) @@ -2862,7 +3049,13 @@ mod tests { ..default() })], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 2, "{issues:?}"); assert!(errs.iter().any(|e| e.message.contains("ForceAlign"))); @@ -2899,6 +3092,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!(errors(&issues).is_empty(), "{issues:?}"); @@ -2907,6 +3101,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!( issues.is_empty(), @@ -2918,6 +3113,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); @@ -2938,7 +3134,13 @@ mod tests { )], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 2, "{issues:?}"); assert!(errs[0].message.contains("ghost_battery")); @@ -2949,7 +3151,13 @@ mod tests { #[test] fn duplicate_spawn_ids_in_one_handler_are_an_error() { let s = scenario(vec![spawn_object("twin"), spawn_object("twin")], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); assert!(errs[0].message.contains("twin")); @@ -2967,7 +3175,13 @@ mod tests { filters: vec![], actions: vec![spawn_object("boss")], }); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!(errors(&issues).is_empty(), "warn-only: {issues:?}"); assert_eq!(issues.len(), 1, "{issues:?}"); assert!(issues[0].message.contains("mutually exclusive")); @@ -3004,6 +3218,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!( errors(&issues) @@ -3018,6 +3233,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!( errors(&issues).is_empty(), @@ -3084,7 +3300,13 @@ mod tests { } let lint_of = |action| { let s = scenario(vec![action], vec![]); - lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])) + lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ) }; let kinds: [(&str, fn(MetersPerSecond3) -> ScenarioObjectConfig); 2] = [("ship", ship), ("asteroid", asteroid)]; @@ -3155,7 +3377,13 @@ mod tests { }; let lint_of = |action| { let s = scenario(vec![action], vec![]); - lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])) + lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ) }; let issues = lint_of(field(vec![])); @@ -3213,7 +3441,13 @@ mod tests { })], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!( errors(&issues) .iter() @@ -3255,7 +3489,13 @@ mod tests { }), ], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "only the ghost flags: {issues:?}"); assert!(errs[0].message.contains("ghost")); @@ -3293,7 +3533,13 @@ mod tests { ..Default::default() })], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!( errors(&issues).iter().any(|i| i.message.contains("ghost")), "a blank prefix satisfies nothing, so the dangling id still errors: {issues:?}" @@ -3319,7 +3565,13 @@ mod tests { ), ))], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!(errors(&issues).is_empty(), "warn-only: {issues:?}"); assert_eq!(issues.len(), 2, "{issues:?}"); assert!(issues.iter().any(|i| i.message.contains("never_set"))); @@ -3347,16 +3599,34 @@ mod tests { }; let s = scenario(vec![outcome(), next(false, None)], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert_eq!(issues.len(), 1, "{issues:?}"); assert!(issues[0].message.contains("non-lingering")); let s = scenario(vec![outcome(), next(false, Some(4.0))], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert_eq!(issues.len(), 1, "delayed is the same trap: {issues:?}"); let s = scenario(vec![outcome(), next(true, None)], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!( issues.is_empty(), "the lingering pair is the good shape: {issues:?}" @@ -3385,19 +3655,36 @@ mod tests { }; let s = scenario(vec![line("one"), line("two")], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert_eq!(issues.len(), 1, "{issues:?}"); assert!(issues[0].message.contains("one line per beat")); let s = scenario(vec![line("dead"), outcome()], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert_eq!(issues.len(), 1, "{issues:?}"); assert!(issues[0].message.contains("never read")); let s = scenario(vec![line("solo")], vec![]); - assert!( - lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"]),).is_empty() - ); + assert!(lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ) + .is_empty()); } /// Pacing-field ranges: absurd/non-finite delays warn, a delay on a @@ -3422,7 +3709,13 @@ mod tests { // Range/dead-field warns, isolated from the same-handler swallow // trap (which is its own test): switches only. let s = scenario(vec![next(false, Some(1e30)), next(true, Some(4.0))], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!(errors(&issues).is_empty(), "warn-only: {issues:?}"); assert_eq!(issues.len(), 2, "{issues:?}"); assert!(issues.iter().any(|i| i.message.contains("outside (0, 60]"))); @@ -3430,7 +3723,13 @@ mod tests { // The outcome range warn, without a hard switch in the handler. let s = scenario(vec![outcome_adv(Some(f64::INFINITY))], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert_eq!(issues.len(), 1, "{issues:?}"); assert!(issues[0].message.contains("auto_advance_secs")); @@ -3438,23 +3737,45 @@ mod tests { // Timer that finishes on tick one, so the banner never shows. Both // fields, since both read as "omit the field instead". let s = scenario(vec![outcome_adv(Some(0.0))], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert_eq!(issues.len(), 1, "{issues:?}"); assert!(issues[0].message.contains("auto_advance_secs")); let s = scenario(vec![next(false, Some(0.0))], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert_eq!(issues.len(), 1, "{issues:?}"); assert!(issues[0].message.contains("outside (0, 60]")); // Sane values, trap-free shapes: clean. let s = scenario(vec![next(false, Some(4.0))], vec![]); - assert!( - lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"]),).is_empty() - ); + assert!(lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ) + .is_empty()); let s = scenario(vec![outcome_adv(Some(6.0))], vec![]); - assert!( - lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"]),).is_empty() - ); + assert!(lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ) + .is_empty()); } /// NarrativeCue dwell range: out-of-range warns, in-range and omitted stay @@ -3481,7 +3802,13 @@ mod tests { actions: vec![l], }); } - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!(errors(&issues).is_empty(), "warn-only: {issues:?}"); assert_eq!(issues.len(), 1, "{issues:?}"); assert!(issues[0].message.contains("120")); @@ -3518,6 +3845,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!( issues.is_empty(), @@ -3540,6 +3868,7 @@ mod tests { §ions(&[]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert_eq!( errors(&issues).len(), @@ -3583,6 +3912,7 @@ mod tests { §ions(&["hull"]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!( issues.is_empty(), @@ -3608,6 +3938,7 @@ mod tests { §ions(&["hull"]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert_eq!( errors(&issues).len(), @@ -3675,7 +4006,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( errors(&issues) .iter() @@ -3695,7 +4032,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( errors(&issues) .iter() @@ -3721,7 +4064,13 @@ mod tests { filters: vec![], actions: vec![sequence("outro", vec![SequenceStepConfig::default()])], }); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( !issues .iter() @@ -3755,7 +4104,13 @@ mod tests { ..default() })], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( errors(&issues).is_empty(), "a step's spawn declares its id like a handler's would: {issues:?}" @@ -3780,7 +4135,13 @@ mod tests { )], vec![], ); - let issues = lint_scenario(&paced, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &paced, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( !issues.iter().any(|i| i.message.contains("NarrativeCues")), "clock-spaced steps are one line per beat: {issues:?}" @@ -3796,7 +4157,13 @@ mod tests { )], vec![], ); - let issues = lint_scenario(&burst, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &burst, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( issues.iter().any(|i| i.message.contains("NarrativeCues")), "two lines inside ONE step are still a burst: {issues:?}" @@ -3826,7 +4193,13 @@ mod tests { ))], actions: vec![story("late")], }]; - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( issues .iter() @@ -3864,7 +4237,13 @@ mod tests { ], actions: vec![story("too late")], }]; - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( issues .iter() @@ -3901,7 +4280,13 @@ mod tests { ], actions: vec![story("wave two")], }]; - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( !issues .iter() @@ -3936,7 +4321,13 @@ mod tests { ))], actions: vec![story("all aboard")], }); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( !issues .iter() @@ -3966,7 +4357,13 @@ mod tests { })], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( errors(&issues).iter().any(|issue| { issue.message.contains("Cinematic") @@ -3992,7 +4389,13 @@ mod tests { })], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); errors(&issues) .iter() .filter(|issue| issue.message.contains("PlaySound")) @@ -4023,7 +4426,13 @@ mod tests { })], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert_eq!( errors(&issues) .iter() @@ -4056,7 +4465,13 @@ mod tests { }; let typo = scenario(plays("strike"), filter("strke")); - let issues = lint_scenario(&typo, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &typo, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( errors(&issues) .iter() @@ -4066,7 +4481,13 @@ mod tests { ); let matched = scenario(plays("strike"), filter("strike")); - let issues = lint_scenario(&matched, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &matched, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( !errors(&issues) .iter() @@ -4095,7 +4516,13 @@ mod tests { ], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( issues.iter().any(|issue| { issue.severity == LintSeverity::Warn @@ -4111,7 +4538,13 @@ mod tests { #[test] fn a_slash_in_an_object_or_section_id_is_an_error() { let s = scenario(vec![spawn_object("bad/beacon")], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( errors(&issues).iter().any(|issue| { issue.message.contains("bad/beacon") && issue.message.contains("reserved") @@ -4141,7 +4574,13 @@ mod tests { }), }; let s = scenario(vec![EventActionConfig::SpawnScenarioObject(ship)], vec![]); - let issues = lint_scenario(&s, §ions(&["hull"]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&["hull"]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( errors(&issues).iter().any(|issue| { issue.message.contains("bad/section") && issue.message.contains("reserved") @@ -4200,7 +4639,13 @@ mod tests { }), }; let s = scenario(vec![EventActionConfig::SpawnScenarioObject(ship)], vec![]); - let issues = lint_scenario(&s, §ions(&["hull"]), &ships(&[]), &known(&[])); + let issues = lint_scenario( + &s, + §ions(&["hull"]), + &ships(&[]), + &known(&[]), + &GameItems::default(), + ); assert!( errors(&issues) .iter() diff --git a/crates/nova_scenario/src/lint/ship.rs b/crates/nova_scenario/src/lint/ship.rs index f4152f4f0..3a453e0ec 100644 --- a/crates/nova_scenario/src/lint/ship.rs +++ b/crates/nova_scenario/src/lint/ship.rs @@ -2,7 +2,7 @@ use bevy::prelude::Vec3; use nova_events::units::prelude::*; -use nova_gameplay::prelude::kg_text; +use nova_gameplay::prelude::{kg_text, GameItems}; use nova_ship::prelude::{ candidate_link_point_mates, derive_link_point_graph, duplicate_muzzle_id, muzzle_ids, section_colliders_overlap, AmmoCapacity, CargoIntakeSectionConfig, ControllerSectionConfig, @@ -17,8 +17,8 @@ use crate::prelude::*; /// Every reference a spawned (or scatter-template) ship makes must resolve: /// the design it names, the section prototypes that design is built from, and -/// the sections its spawn-time patches aim at. Its inventory stock must also -/// fit the resolved design's hold. +/// the sections its spawn-time patches aim at. Its inventory stock must name +/// only items `items` holds and fit the resolved design's hold. /// /// A `Prototype` design's own geometry is NOT re-checked here - it is linted /// where the design catalog is walked ([`lint_ship_design_config`]), the same @@ -28,11 +28,22 @@ pub(super) fn check_object_prototypes( scenario: &str, sections: &KnownSections, designs: &KnownShipDesigns, + items: &GameItems, issues: &mut Vec, ) { let ScenarioObjectKind::Spaceship(ship) = &config.kind else { return; }; + let mut known_stock = true; + for (item, _) in ship.inventory.stacks() { + if items.get(item).is_none() { + known_stock = false; + issues.push(LintIssue::error( + scenario, + format!("ship '{}': unknown item '{item}'", config.base.id), + )); + } + } let catalog = GameShipDesigns( ship.design .prototype_id() @@ -49,12 +60,15 @@ pub(super) fn check_object_prototypes( ); let (resolved, errors) = resolve_ship_design(&ship.design, &catalog, sections.catalog()); // The spawn fills the hold from this stock and panics when it does not fit. - // An unknown design already reports its own error and has no hold to fit. + // An unknown design or item already reports its own error, and the stock + // has no hold or no mass to weigh. let known_design = !errors .iter() .any(|error| matches!(error, ShipDesignError::UnknownDesign(_))); - let (stock_g, capacity_g) = (ship.inventory.mass_g(), resolved.cargo_capacity_g()); - if known_design && stock_g > u64::from(capacity_g) { + let capacity_g = resolved.cargo_capacity_g(); + let stock_g = known_stock.then(|| ship.inventory.mass_g(items)); + if let Some(stock_g) = stock_g.filter(|&mass_g| known_design && mass_g > u64::from(capacity_g)) + { issues.push(LintIssue::error( scenario, format!( @@ -950,6 +964,7 @@ mod tests { §ions(&["known_proto"]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); @@ -983,6 +998,7 @@ mod tests { §ions(&["hull"]), &ships(&["block_gunship"]), &known(&["test_scenario"]), + &GameItems::default(), ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); @@ -994,6 +1010,7 @@ mod tests { §ions(&["hull"]), &ships(&["block_gunship"]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!(issues.is_empty(), "a known ship lints clean: {issues:?}"); } @@ -1037,6 +1054,7 @@ mod tests { §ions(&["hull"]), &ships(&["block_gunship"]), &known(&["test_scenario"]), + &GameItems::default(), ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); @@ -1049,6 +1067,7 @@ mod tests { §ions(&["hull"]), &ships(&["block_gunship"]), &known(&["test_scenario"]), + &GameItems::default(), ) .is_empty()); } @@ -1266,7 +1285,13 @@ mod tests { source: SectionSource::prototype("empty"), }); let scenario = scenario(vec![action], vec![]); - let issues = lint_scenario(&scenario, &catalog, &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &scenario, + &catalog, + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!( issues .iter() @@ -1307,6 +1332,7 @@ mod tests { §ions(&["known_proto"]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); let errs = errors(&issues); assert_eq!(errs.len(), 1, "{issues:?}"); @@ -1355,6 +1381,7 @@ mod tests { §ions(&["known"]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert_eq!( issues @@ -1372,6 +1399,7 @@ mod tests { §ions(&["known"]), &ships(&[]), &known(&["test_scenario"]), + &GameItems::default(), ); assert!(issues.is_empty(), "{issues:?}"); } @@ -1436,7 +1464,13 @@ mod tests { )], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert_eq!( issues .iter() @@ -1454,7 +1488,13 @@ mod tests { )], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!( issues .iter() @@ -1471,7 +1511,13 @@ mod tests { )], vec![], ); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert_eq!( issues .iter() @@ -1493,7 +1539,13 @@ mod tests { a.rotation = Quat::from_rotation_x(std::f32::consts::FRAC_PI_2); b.rotation = Quat::from_rotation_x(std::f32::consts::FRAC_PI_2); let s = scenario(vec![ship(a, b)], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!( issues .iter() @@ -1509,7 +1561,13 @@ mod tests { a.rotation = Quat::from_rotation_x(std::f32::consts::FRAC_PI_2); b.rotation = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2); let s = scenario(vec![ship(a, b)], vec![]); - let issues = lint_scenario(&s, §ions(&[]), &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + §ions(&[]), + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!( issues .iter() @@ -1574,7 +1632,13 @@ mod tests { fn directly_mated_sections_may_overlap() { let (action, catalog) = ship_with_mated_overlap(); let s = scenario(vec![action], vec![]); - let issues = lint_scenario(&s, &catalog, &ships(&[]), &known(&["test_scenario"])); + let issues = lint_scenario( + &s, + &catalog, + &ships(&[]), + &known(&["test_scenario"]), + &GameItems::default(), + ); assert!(issues.is_empty(), "{issues:?}"); } diff --git a/crates/nova_scenario/src/loader/lifecycle.rs b/crates/nova_scenario/src/loader/lifecycle.rs index eff78383c..5369477a1 100644 --- a/crates/nova_scenario/src/loader/lifecycle.rs +++ b/crates/nova_scenario/src/loader/lifecycle.rs @@ -161,7 +161,7 @@ pub(super) fn unload_scenario( } /// The merged content the start gate lints a scenario against, as one system -/// parameter rather than five: they are read together, in one place, and the +/// parameter rather than six: they are read together, in one place, and the /// observer is already near Bevy's parameter ceiling. /// /// Every one is optional because a rig that merged no content still loads @@ -172,6 +172,7 @@ pub(super) struct ContentGate<'w> { sections: Option>, ships: Option>, scenarios: Option>, + items: Option>, } /// The Error-level findings against a scenario about to start: what the merge @@ -216,11 +217,19 @@ fn start_errors(scenario: &ScenarioConfig, gate: &ContentGate) -> Vec { // that never merged content. known_scenarios.insert(scenario.id.clone()); + let no_items = GameItems::default(); + let known_items = gate.items.as_deref().unwrap_or(&no_items); messages.extend( - lint_scenario(scenario, &known_sections, &known_ships, &known_scenarios) - .into_iter() - .filter(|issue| issue.severity == LintSeverity::Error) - .map(|issue| issue.message), + lint_scenario( + scenario, + &known_sections, + &known_ships, + &known_scenarios, + known_items, + ) + .into_iter() + .filter(|issue| issue.severity == LintSeverity::Error) + .map(|issue| issue.message), ); let mut seen = std::collections::HashSet::new(); messages.retain(|message| seen.insert(message.clone())); diff --git a/crates/nova_scenario/src/mining.rs b/crates/nova_scenario/src/mining.rs index 2383566ba..b259d26d6 100644 --- a/crates/nova_scenario/src/mining.rs +++ b/crates/nova_scenario/src/mining.rs @@ -148,14 +148,15 @@ const SPARK_UP_PROPERTY: &str = "up"; /// The ore a pulse into an asteroid of `kind` yields, or `None` for a kind /// that holds none, such as `plain`, the unshaded control rock. -pub fn ore_for_asteroid_kind(kind: &AsteroidKindId) -> Option { - match kind.as_str() { - KIND_ROCK => Some(ItemType::StoneOre), - KIND_METAL => Some(ItemType::IronOre), - KIND_ICE => Some(ItemType::WaterIce), - KIND_CARBON => Some(ItemType::CarbonOre), - _ => None, - } +pub fn ore_for_asteroid_kind(kind: &AsteroidKindId) -> Option { + let ore = match kind.as_str() { + KIND_ROCK => ITEM_STONE_ORE, + KIND_METAL => ITEM_IRON_ORE, + KIND_ICE => ITEM_WATER_ICE, + KIND_CARBON => ITEM_CARBON_ORE, + _ => return None, + }; + Some(ore.into()) } /// Why a pulse takes nothing, in check order. @@ -198,12 +199,12 @@ pub struct MiningBeamHit { } /// Ore a rock node owes for corners mined since its last validated remesh. -#[derive(Component, Clone, Copy, Debug, PartialEq)] +#[derive(Component, Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] pub struct MinedOre { /// The ore the rock's kind yields. - item: ItemType, + item: ItemDesignId, /// Corners flipped, one item each. corners: u32, /// The last pulse's surface point, in the node's local frame. @@ -213,6 +214,11 @@ pub struct MinedOre { } impl MinedOre { + /// The ore the rock's kind yields. + pub(crate) fn item(&self) -> &ItemDesignId { + &self.item + } + /// Add `later`'s corners and take its surface point. fn absorb(&mut self, later: MinedOre) { self.corners += later.corners; @@ -272,6 +278,15 @@ pub struct FrozenOreDrop { queue: MinedCanisterQueue, } +impl FrozenOreDrop { + /// Every item id its waiting canisters hold. + pub fn item_ids(&self) -> impl Iterator { + self.queue + .canisters() + .flat_map(|canister| canister.stacks().map(|(item, _)| item)) + } +} + /// Snapshot one ore drop's whole state so its sector can despawn it and /// [`thaw_ore_drop`] can put the same drop back later. /// @@ -378,7 +393,7 @@ struct BeamAim { /// The rock's field node. node: Entity, /// The ore the rock yields. - item: ItemType, + item: ItemDesignId, /// Where the ray meets the rock's collider, in world space. at: Vec3, /// The collider's outward normal there. @@ -588,7 +603,7 @@ fn carve( return 0; } let pulse = MinedOre { - item: aim.item, + item: aim.item.clone(), corners, at, normal: local_normal, @@ -596,9 +611,9 @@ fn carve( match owed.get_mut(&aim.node) { Some(pending) => pending.absorb(pulse), None => { - let mut total = mined.copied().unwrap_or(MinedOre { + let mut total = mined.cloned().unwrap_or(MinedOre { corners: 0, - ..pulse + ..pulse.clone() }); total.absorb(pulse); owed.insert(aim.node, total); @@ -876,6 +891,7 @@ fn release_mined_ore( )>, q_parents: Query<&ChildOf>, q_frames: Query<&GlobalTransform>, + items: Res, ) { let node = remeshed.entity; let Ok((frame, &ChildOf(root), owed, queue)) = q_nodes.get_mut(node) else { @@ -883,12 +899,14 @@ fn release_mined_ore( }; let mut waiting = queue.map_or_else(VecDeque::new, |queue| queue.waiting.clone()); if let Some(owed) = owed { - let per_canister = CARGO_CANISTER_MAX_MASS_G / owed.item.mass_g(); + // Content lint holds every ore under one canister's mass, so this is + // at least one. + let per_canister = CARGO_CANISTER_MAX_MASS_G / items.design(&owed.item).mass_g; let mut rest = owed.corners; while rest > 0 { let count = rest.min(per_canister); waiting.push_back(MinedCanister { - canister: CargoCanister::new(owed.item, count), + canister: CargoCanister::new(&items, &owed.item, count), at: owed.at, normal: owed.normal, }); @@ -1077,7 +1095,7 @@ impl Plugin for MiningPlugin { mod tests { use avian3d::prelude::*; use nova_events::prelude::*; - use nova_gameplay::test_support::{settle, unfinished_integrity_physics_app}; + use nova_gameplay::test_support::{settle, test_items, unfinished_integrity_physics_app}; use super::*; use crate::{ @@ -1134,6 +1152,7 @@ mod tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.insert_resource(test_items()); app.init_asset::(); app.add_observer(|pulse: On, mut pulses: ResMut| { pulses.0.push((pulse.entity, pulse.outcome)); @@ -1258,11 +1277,11 @@ mod tests { /// Ore of `item` the world holds anywhere between a pulse and a hold: /// owed on a rock, queued for birth, or drifting in a canister. - fn ore_in_world(world: &mut World, item: ItemType) -> u32 { + fn ore_in_world(world: &mut World, item: &ItemDesignId) -> u32 { let owed: u32 = world .query::<&MinedOre>() .iter(world) - .filter(|owed| owed.item == item) + .filter(|owed| owed.item == *item) .map(|owed| owed.corners) .sum(); let queued: u32 = world @@ -1274,7 +1293,7 @@ mod tests { owed + queued + drifting(world, item) } - fn held(canister: &CargoCanister, item: ItemType) -> u32 { + fn held(canister: &CargoCanister, item: &ItemDesignId) -> u32 { canister .stacks() .filter(|(each, _)| *each == item) @@ -1282,7 +1301,7 @@ mod tests { .sum() } - fn drifting(world: &mut World, item: ItemType) -> u32 { + fn drifting(world: &mut World, item: &ItemDesignId) -> u32 { world .query::<&CargoCanister>() .iter(world) @@ -1328,7 +1347,10 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(1)); let paid = app.world().resource::().paid(); let world = app.world_mut(); - assert_eq!(ore_in_world(world, ItemType::StoneOre), paid); + assert_eq!( + ore_in_world(world, &ItemDesignId::from(ITEM_STONE_ORE)), + paid + ); let drifting_now = canisters(world); if !drifting_now.is_empty() { assert!( @@ -1363,7 +1385,7 @@ mod tests { ); } let world = app.world_mut(); - assert_eq!(drifting(world, ItemType::StoneOre), paid); + assert_eq!(drifting(world, &ItemDesignId::from(ITEM_STONE_ORE)), paid); let mut minted = Vec::new(); for canister in canisters(world) { let id = world @@ -1378,7 +1400,8 @@ mod tests { .stacks() .collect(); assert!( - held.iter().all(|(item, _)| *item == ItemType::StoneOre), + held.iter() + .all(|(item, _)| *item == &ItemDesignId::from(ITEM_STONE_ORE)), "{held:?}" ); assert!(world.get::(canister).is_some()); @@ -1529,12 +1552,13 @@ mod tests { app.update(); let world = app.world_mut(); for item in [ - ItemType::StoneOre, - ItemType::IronOre, - ItemType::WaterIce, - ItemType::CarbonOre, + ITEM_STONE_ORE, + ITEM_IRON_ORE, + ITEM_WATER_ICE, + ITEM_CARBON_ORE, ] { - assert_eq!(ore_in_world(world, item), 0, "{item:?}"); + let item = ItemDesignId::from(item); + assert_eq!(ore_in_world(world, &item), 0, "{item:?}"); } } @@ -1619,6 +1643,7 @@ mod tests { /// time, and despawns with the last: all 45 ore, none lost. #[test] fn an_exhausted_rock_keeps_blocked_canisters_until_the_birth_point_clears() { + let iron = ItemDesignId::from(ITEM_IRON_ORE); let mut app = mining_app(); let sector = app .world_mut() @@ -1635,7 +1660,7 @@ mod tests { Visibility::default(), ChildOf(root), MinedOre { - item: ItemType::IronOre, + item: iron.clone(), corners: 45, at: Vec3::new(0.5, 0.0, 0.0), normal: Vec3::X, @@ -1673,7 +1698,7 @@ mod tests { .map(|(drop, queue, child_of, frame)| { let counts: Vec = queue .canisters() - .map(|canister| held(canister, ItemType::IronOre)) + .map(|canister| held(canister, &iron)) .collect(); (drop, counts, child_of.parent(), frame.translation()) }) @@ -1694,10 +1719,7 @@ mod tests { let world = app.world_mut(); for canister in canisters(world) { if !born.iter().any(|(each, _)| *each == canister) { - let count = held( - world.get::(canister).unwrap(), - ItemType::IronOre, - ); + let count = held(world.get::(canister).unwrap(), &iron); assert!(world.get::(canister).is_some()); born.push((canister, count)); } @@ -1720,15 +1742,16 @@ mod tests { /// corners again, and the drop drains its queue as it would have. #[test] fn owed_ore_and_a_waiting_drop_come_back_from_a_frozen_sector() { + let iron = ItemDesignId::from(ITEM_IRON_ORE); let mut app = mining_app(); let (rock, node) = spawn_rock(&mut app, KIND_ROCK); let owed = MinedOre { - item: ItemType::IronOre, + item: iron.clone(), corners: 12, at: Vec3::new(0.5, 0.0, 0.0), normal: Vec3::X, }; - app.world_mut().entity_mut(node).insert(owed); + app.world_mut().entity_mut(node).insert(owed.clone()); // A drop far from the rock, its birth point blocked. let sector = app @@ -1746,7 +1769,7 @@ mod tests { Visibility::default(), ChildOf(exhausted), MinedOre { - item: ItemType::IronOre, + item: iron.clone(), corners: 45, at: Vec3::new(0.5, 0.0, 0.0), normal: Vec3::X, @@ -1778,7 +1801,7 @@ mod tests { else { panic!("the exhausted rock must leave one drop"); }; - assert_eq!(ore_in_world(world, ItemType::IronOre), 12 + 45); + assert_eq!(ore_in_world(world, &iron), 12 + 45); let frozen_rock = freeze_asteroid(world, rock).expect("a settled rock freezes"); let frozen_drop = freeze_ore_drop(world, drop); @@ -1786,7 +1809,7 @@ mod tests { world.entity_mut(rock).despawn(); world.entity_mut(drop).despawn(); assert_eq!( - ore_in_world(world, ItemType::IronOre), + ore_in_world(world, &iron), 0, "frozen ore must not stay in the world" ); @@ -1826,7 +1849,7 @@ mod tests { Some(&owed), "the rock is owed the same ore" ); - assert_eq!(ore_in_world(world, ItemType::IronOre), 12 + 45); + assert_eq!(ore_in_world(world, &iron), 12 + 45); world.entity_mut(blocker).despawn(); let mut born = Vec::new(); @@ -1835,10 +1858,7 @@ mod tests { let world = app.world_mut(); for canister in canisters(world) { if !born.iter().any(|(each, _)| *each == canister) { - let count = held( - world.get::(canister).unwrap(), - ItemType::IronOre, - ); + let count = held(world.get::(canister).unwrap(), &iron); born.push((canister, count)); } } diff --git a/crates/nova_scenario/src/objects/asteroid.rs b/crates/nova_scenario/src/objects/asteroid.rs index 8a3220905..b1cabd4a0 100644 --- a/crates/nova_scenario/src/objects/asteroid.rs +++ b/crates/nova_scenario/src/objects/asteroid.rs @@ -458,6 +458,17 @@ pub struct FrozenAsteroid { } impl FrozenAsteroid { + /// Every item id the rock still owes: its pending ore and its waiting + /// canisters. + pub fn item_ids(&self) -> impl Iterator { + self.mined_ore.iter().map(MinedOre::item).chain( + self.mined_queue + .iter() + .flat_map(MinedCanisterQueue::canisters) + .flat_map(|canister| canister.stacks().map(|(item, _)| item)), + ) + } + /// The silhouette seed its pristine geometry is prepared from. pub fn seed(&self) -> u32 { self.seed @@ -586,7 +597,7 @@ pub fn freeze_asteroid(world: &World, asteroid: Entity) -> Result(node).copied(), + mined_ore: world.get::(node).cloned(), mined_queue: world.get::(node).cloned(), }) } diff --git a/crates/nova_scenario/src/objects/beacon.rs b/crates/nova_scenario/src/objects/beacon.rs index 91f9535f0..fd917c5a9 100644 --- a/crates/nova_scenario/src/objects/beacon.rs +++ b/crates/nova_scenario/src/objects/beacon.rs @@ -230,6 +230,7 @@ mod tests { use bevy::time::TimeUpdateStrategy; use nova_events::prelude::{EventHandler, GameEventsPlugin}; + use nova_gameplay::test_support::test_items; use super::*; @@ -379,9 +380,12 @@ mod tests { beacon_scenario_object(config(Some(Meters(100.0)))), Transform::IDENTITY, )); + let items = test_items(); + let hull_plate = ItemDesignId::from(ITEM_HULL_PLATE); let inventory = ShipInventory::new( - ItemType::HullPlate.mass_g() * 10, - [(ItemType::HullPlate, 3)], + &items, + items.design(&hull_plate).mass_g * 10, + [(hull_plate.clone(), 3)], ); // Three section colliders on one rigid body, as a ship's sections are, // flying from outside the 10-unit area to beyond its far side. diff --git a/crates/nova_scenario/src/objects/spaceship.rs b/crates/nova_scenario/src/objects/spaceship.rs index 1be89c0cf..3f821033b 100644 --- a/crates/nova_scenario/src/objects/spaceship.rs +++ b/crates/nova_scenario/src/objects/spaceship.rs @@ -594,6 +594,17 @@ struct FrozenShipState { } impl FrozenShip { + /// Every item id the ship holds: its hold's stacks and what each + /// surviving section holds, see [`FrozenSection::item_ids`]. + pub fn item_ids(&self) -> impl Iterator { + self.state.inventory.stacks().map(|(item, _)| item).chain( + self.state + .sections + .values() + .flat_map(FrozenSection::item_ids), + ) + } + /// Whether section `section` survived to freeze: whether it still has a /// state entry a thaw would spawn. #[must_use] @@ -790,22 +801,24 @@ pub fn thaw_ship(entity: &mut EntityCommands, frozen: FrozenShip) { /// Spawns spaceship scenario objects: resolves each ship's hull and section /// list into child section entities and wires the player/AI controller. /// Adds the `Add` section-insert observer, seeds empty -/// [`GameSections`] and [`GameShipDesigns`] catalogs, and registers the -/// section-modification components and their apply-on-add observers. +/// [`GameSections`], [`GameShipDesigns`], and [`GameItems`] catalogs, and +/// registers the section-modification components and their apply-on-add +/// observers. pub struct SpaceshipPlugin; impl Plugin for SpaceshipPlugin { fn build(&self, app: &mut App) { trace!("SpaceshipPlugin: build"); - // `insert_spaceship_sections` resolves Prototype sources against - // `GameSections` and `GameShipDesigns`, so the plugin self-provides - // (empty) defaults: production and the editor overwrite them with the - // loaded catalogs, and Inline-only spawns (examples, previews) then - // need no catalog wiring. Makes both resource dependencies - // self-satisfying instead of a spawn-order footgun. + // `insert_spaceship_sections` reads `GameSections`, `GameShipDesigns`, + // and `GameItems`, so the plugin self-provides empty defaults for all + // three: production and the editor replace them with the merged + // catalogs, and Inline-only spawns with empty stock (examples, + // previews) then need no catalog wiring. Nonempty stock still needs + // the merged item catalog; an unknown item panics at spawn. app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.add_observer(insert_spaceship_sections); } @@ -816,6 +829,7 @@ fn insert_spaceship_sections( mut commands: Commands, game_sections: Res, game_designs: Res, + items: Res, q_spaceship: Query< ( &SpaceshipDesign, @@ -889,7 +903,11 @@ fn insert_spaceship_sections( rcs_loop: presentation.rcs_loop_sound.clone(), }, ShipHullWarning(presentation.warn_hull_fraction.clamp(0.0, 1.0)), - ShipInventory::new(design.cargo_capacity_g(), stock.stacks()), + ShipInventory::new( + &items, + design.cargo_capacity_g(), + stock.stacks().map(|(item, count)| (item.clone(), count)), + ), )); commands.entity(entity).remove::(); @@ -1195,6 +1213,8 @@ fn insert_spaceship_sections( #[cfg(test)] mod tests { + use nova_gameplay::test_support::test_items; + use super::*; use crate::objects::ship_design::prelude::{ ShipDesign, ShipDesignPrototype, ShipIntegrityConfig, @@ -1210,6 +1230,7 @@ mod tests { // Inline hulls and sections, so empty catalogs are fine. world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let spawn = |world: &mut World, config: AIControllerConfig| { @@ -1372,6 +1393,7 @@ mod tests { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let blind = world .spawn(( @@ -1402,6 +1424,7 @@ mod tests { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let boarder = world .spawn(( @@ -1434,6 +1457,7 @@ mod tests { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let miner = world .spawn(( @@ -1463,6 +1487,7 @@ mod tests { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let ship = world @@ -1494,6 +1519,7 @@ mod tests { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let turret_section = || SpaceshipSectionConfig { @@ -1583,6 +1609,7 @@ mod tests { let world = app.world_mut(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let ship = world @@ -1637,6 +1664,7 @@ mod tests { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let kinds = [ @@ -1718,6 +1746,7 @@ mod tests { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let spawn = |world: &mut World, config: AIControllerConfig| { let entity = world @@ -1766,6 +1795,7 @@ mod tests { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let spawn = |world: &mut World, collapse_threshold| { let entity = world @@ -1871,6 +1901,7 @@ mod tests { ..default() }, }])); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let entity = world @@ -1926,6 +1957,7 @@ mod tests { let mut world = World::new(); world.init_resource::(); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let entity = world @@ -1992,6 +2024,7 @@ mod tests { let mut world = World::new(); world.insert_resource(GameSections(catalog.0.clone())); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let sections = sources @@ -2045,6 +2078,7 @@ mod tests { let mut world = World::new(); world.insert_resource(GameSections(vec![section_prototype("plate")])); world.init_resource::(); + world.insert_resource(test_items()); world.add_observer(insert_spaceship_sections); let hull = |id: &str, z| SpaceshipSectionConfig { id: id.to_string(), @@ -2060,7 +2094,10 @@ mod tests { sections: vec![hull("bow", 0.0), hull("stern", 1.0)], ..default() }), - inventory: ShipInventoryStock::new([(ItemType::HullPlate, plates)]), + inventory: ShipInventoryStock::new([( + ItemDesignId::from(ITEM_HULL_PLATE), + plates, + )]), ..default() }), )) @@ -2077,8 +2114,8 @@ mod tests { let inventory = world.entity(ship).get::().unwrap(); assert_eq!(inventory.capacity_g(), 200_000); - assert_eq!(inventory.used_g(), 120_000); - assert_eq!(inventory.count(ItemType::HullPlate), 12); + assert_eq!(inventory.used_g(&test_items()), 120_000); + assert_eq!(inventory.count(&ItemDesignId::from(ITEM_HULL_PLATE)), 12); assert!(world.entity(ship).get::().is_none()); } diff --git a/crates/nova_scenario/src/tests/docked_helm.rs b/crates/nova_scenario/src/tests/docked_helm.rs index bec1ef35f..ca1dc4c1d 100644 --- a/crates/nova_scenario/src/tests/docked_helm.rs +++ b/crates/nova_scenario/src/tests/docked_helm.rs @@ -11,7 +11,7 @@ use bevy::prelude::*; use nova_events::prelude::MetersPerSecond3; use nova_gameplay::{ prelude::*, - test_support::{settle, unfinished_integrity_physics_app}, + test_support::{settle, test_items, unfinished_integrity_physics_app}, }; use nova_ship::{flight::prelude::*, prelude::*}; use serde::Deserialize; @@ -136,6 +136,7 @@ fn app() -> App { app.insert_resource(sections); app.insert_resource(ships); app.init_resource::(); + app.insert_resource(test_items()); app.add_systems( FixedUpdate, slew_helm diff --git a/crates/nova_ship/src/input/targeting/radar.rs b/crates/nova_ship/src/input/targeting/radar.rs index 5c753b6be..54fcf3640 100644 --- a/crates/nova_ship/src/input/targeting/radar.rs +++ b/crates/nova_ship/src/input/targeting/radar.rs @@ -239,6 +239,7 @@ pub(super) fn lock_dwell_secs(distance: f32, modifier: f32, settings: &Targeting mod tests { use avian3d::prelude::*; use bevy::ecs::system::RunSystemOnce; + use nova_gameplay::test_support::test_items; use super::*; @@ -547,7 +548,7 @@ mod tests { assert!(at.translation.length() > TargetingSettings::default().unsigned_lock_range); let canister = world .spawn(cargo_canister( - CargoCanister::new(ItemType::HullPlate, 1), + CargoCanister::new(&test_items(), &ITEM_HULL_PLATE.into(), 1), at, Vec3::ZERO, AssetRef::from("canister.glb#Scene0"), diff --git a/crates/nova_ship/src/sections/ammo.rs b/crates/nova_ship/src/sections/ammo.rs index 0227d0a15..f080d20e2 100644 --- a/crates/nova_ship/src/sections/ammo.rs +++ b/crates/nova_ship/src/sections/ammo.rs @@ -13,7 +13,7 @@ //! //! A section may also carry a [`SectionReload`] (seeded from a //! [`SectionReloadConfig`] on the weapon config and the weapon's ammunition -//! [`ItemType`]). Every successful shot resets its timer; each uninterrupted +//! item id). Every successful shot resets its timer; each uninterrupted //! delay moves one batch of that item from the parent ship's [`ShipInventory`] //! into the magazine, never more than the magazine is missing or the ship //! carries. A ship with no matching item makes no reload progress. An @@ -28,7 +28,7 @@ //! [`TorpedoSectionConfig`]: super::torpedo_section::TorpedoSectionConfig use bevy::prelude::*; -use nova_gameplay::prelude::{ItemType, SectionInactiveMarker, ShipInventory}; +use nova_gameplay::prelude::{ItemDesignId, SectionInactiveMarker, ShipInventory}; /// `SectionAmmo`, `SectionReload` and `SectionReloadConfig`. pub mod prelude { @@ -100,7 +100,7 @@ impl SectionAmmo { /// /// Only sections that HAD a magazine carry this, so a weapon authored unlimited /// stays unlimited when the cheat goes off. -#[derive(Component, Clone, Copy, Debug, Reflect)] +#[derive(Component, Clone, Debug, Reflect)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] pub struct SuspendedSectionAmmo { @@ -189,12 +189,12 @@ pub struct SectionReloadConfig { /// ship's [`ShipInventory`]. Carries the authored parameters plus the in-flight /// cycle progress so the HUD ammo readout can render a reload/recharge state /// without a second source of truth. -#[derive(Component, Clone, Copy, Debug, Reflect)] +#[derive(Component, Clone, Debug, Reflect)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] pub struct SectionReload { /// The inventory item one round of this magazine consumes. - pub item: ItemType, + pub item: ItemDesignId, /// Seconds without a shot before one batch returns. pub delay: f32, /// Rounds restored by one completed delay. @@ -209,7 +209,7 @@ pub struct SectionReload { impl SectionReload { /// Seed runtime reload state from authored parameters and the weapon's /// ammunition `item`. - pub fn from_config(config: SectionReloadConfig, item: ItemType) -> Self { + pub fn from_config(config: SectionReloadConfig, item: ItemDesignId) -> Self { debug_assert!( config.delay > 0.0 && config.delay.is_finite(), "SectionReloadConfig.delay must be positive and finite (got {})", @@ -266,7 +266,7 @@ impl SectionReload { /// delay does not advance. A successful shot earlier in this tick wins the /// boundary and starts a fresh delay. pub fn advance(&mut self, ammo: &mut SectionAmmo, inventory: &mut ShipInventory, dt: f32) { - if !self.is_reloading(ammo, inventory.count(self.item)) { + if !self.is_reloading(ammo, inventory.count(&self.item)) { // Full, or nothing to load: a delay never runs toward a batch // that cannot move. if ammo.rounds >= ammo.capacity { @@ -282,10 +282,10 @@ impl SectionReload { self.elapsed += dt; while self.delay > 0.0 && self.elapsed >= self.delay { self.elapsed -= self.delay; - let rounds = self.batch_rounds(ammo, inventory.count(self.item)); - inventory.remove(self.item, rounds); + let rounds = self.batch_rounds(ammo, inventory.count(&self.item)); + inventory.remove(&self.item, rounds); ammo.rounds += rounds; - if !self.is_reloading(ammo, inventory.count(self.item)) { + if !self.is_reloading(ammo, inventory.count(&self.item)) { self.elapsed = 0.0; break; } @@ -368,6 +368,11 @@ pub struct SectionReloadComplete { #[cfg(test)] mod tests { + use nova_gameplay::{ + prelude::{ITEM_PDC_ROUND, ITEM_TORPEDO}, + test_support::test_items, + }; + use super::*; #[test] @@ -400,15 +405,16 @@ mod tests { } fn reload_cfg(delay: f32, amount: u32) -> SectionReload { - SectionReload::from_config(SectionReloadConfig { delay, amount }, ItemType::PdcRound) + SectionReload::from_config(SectionReloadConfig { delay, amount }, ITEM_PDC_ROUND.into()) } /// A hold of `rounds` PDC rounds with room to spare. fn reserve(rounds: u32) -> ShipInventory { + let items = test_items(); if rounds == 0 { - return ShipInventory::new(1_000_000, []); + return ShipInventory::new(&items, 1_000_000, []); } - ShipInventory::new(1_000_000, [(ItemType::PdcRound, rounds)]) + ShipInventory::new(&items, 1_000_000, [(ITEM_PDC_ROUND.into(), rounds)]) } #[test] @@ -423,12 +429,12 @@ mod tests { assert!((reload.progress() - 2.0 / 3.0).abs() < 1e-6); reload.advance(&mut ammo, &mut inventory, 1.0); assert_eq!(ammo.rounds, 200); - assert_eq!(inventory.count(ItemType::PdcRound), 800); + assert_eq!(inventory.count(&ITEM_PDC_ROUND.into()), 800); assert_eq!(reload.progress(), 0.0); reload.advance(&mut ammo, &mut inventory, 6.0); assert_eq!(ammo.rounds, 500, "the final batch clamps to capacity"); assert_eq!( - inventory.count(ItemType::PdcRound), + inventory.count(&ITEM_PDC_ROUND.into()), 500, "the clamped batch takes only the 100 rounds it loads" ); @@ -455,7 +461,7 @@ mod tests { assert_eq!(reload.progress(), 0.0); // Stock that arrives later starts a fresh delay, not an owed batch. - inventory.add(ItemType::PdcRound, 10); + inventory.add(&test_items(), &ITEM_PDC_ROUND.into(), 10); reload.advance(&mut ammo, &mut inventory, 1.0); assert_eq!(ammo.rounds, 150); reload.advance(&mut ammo, &mut inventory, 2.0); @@ -472,12 +478,12 @@ mod tests { delay: 1.0, amount: 1, }, - ItemType::Torpedo, + ITEM_TORPEDO.into(), ); let mut inventory = reserve(1000); reload.advance(&mut ammo, &mut inventory, 5.0); assert_eq!(ammo.rounds, 0, "PDC rounds do not load a torpedo bay"); - assert_eq!(inventory.count(ItemType::PdcRound), 1000); + assert_eq!(inventory.count(&ITEM_PDC_ROUND.into()), 1000); } #[test] @@ -528,7 +534,7 @@ mod tests { assert!(!reload.is_reloading(&ammo, 10)); reload.advance(&mut ammo, &mut inventory, 100.0); assert_eq!(ammo.rounds, 4); - assert_eq!(inventory.count(ItemType::PdcRound), 10); + assert_eq!(inventory.count(&ITEM_PDC_ROUND.into()), 10); assert_eq!(reload.progress(), 0.0); } @@ -617,7 +623,7 @@ mod tests { .world() .get::(ship) .unwrap() - .count(ItemType::PdcRound); + .count(&ITEM_PDC_ROUND.into()); (loaded, held) }; diff --git a/crates/nova_ship/src/sections/cargo_intake_section.rs b/crates/nova_ship/src/sections/cargo_intake_section.rs index dfc48eb67..92b173119 100644 --- a/crates/nova_ship/src/sections/cargo_intake_section.rs +++ b/crates/nova_ship/src/sections/cargo_intake_section.rs @@ -289,6 +289,11 @@ impl FrozenCanister { pub fn id(&self) -> CargoCanisterRuntimeId { self.id } + + /// Every item id the canister holds. + pub fn item_ids(&self) -> impl Iterator { + self.contents.stacks().map(|(item, _)| item) + } } /// Capture `canister`'s id, contents, health and model. @@ -512,6 +517,7 @@ fn run_cargo_intakes( collisions: Collisions, mut readiness: ResMut, mut canister_ids: ResMut, + items: Res, ) { readiness.pairs.clear(); let mut canisters: Vec = q_canisters @@ -558,7 +564,7 @@ fn run_cargo_intakes( !read.taken && offset.dot(normal) >= 0.0 && offset.length() <= detection_range - && inventory.free_g() >= read.canister.total_mass_g() + && inventory.free_g(&items) >= read.canister.total_mass_g(&items) }); let target = if wanted { 1.0 } else { 0.0 }; if let Some(was) = animations.cue_target(SectionAnimationCue::IntakeDoor) { @@ -581,7 +587,7 @@ fn run_cargo_intakes( manifold.points.iter().any(|point| point.penetration >= 0.0) }) }) - }) && inventory.free_g() >= read.canister.total_mass_g(); + }) && inventory.free_g(&items) >= read.canister.total_mass_g(&items); readiness.pairs.push(CargoPickupPair { ship, intake, @@ -592,7 +598,7 @@ fn run_cargo_intakes( continue; } for (item, count) in read.canister.stacks() { - inventory.add(item, count); + inventory.add(&items, item, count); } commands.entity(read.entity).despawn(); commands.trigger(CargoCanisterTaken { entity: intake }); diff --git a/crates/nova_ship/src/sections/cargo_intake_section/tests.rs b/crates/nova_ship/src/sections/cargo_intake_section/tests.rs index efcba79c5..17e9b8cec 100644 --- a/crates/nova_ship/src/sections/cargo_intake_section/tests.rs +++ b/crates/nova_ship/src/sections/cargo_intake_section/tests.rs @@ -7,7 +7,7 @@ use std::{collections::BTreeMap, f32::consts::FRAC_1_SQRT_2}; -use nova_gameplay::test_support::{settle, unfinished_integrity_physics_app}; +use nova_gameplay::test_support::{settle, test_items, unfinished_integrity_physics_app}; use super::*; @@ -53,7 +53,9 @@ fn intakes_app(stock: u32, intakes_x: &[f32]) -> (App, Entity, Vec) { app.add_observer(|_: On, mut takes: ResMut| { takes.0 += 1; }); + app.insert_resource(test_items()); app.finish(); + let items = app.world().resource::().clone(); let ship = app .world_mut() .spawn(( @@ -61,7 +63,7 @@ fn intakes_app(stock: u32, intakes_x: &[f32]) -> (App, Entity, Vec) { SpaceshipRootMarker, RigidBody::Dynamic, Transform::default(), - ShipInventory::new(400_000, [(ItemType::HullPlate, stock)]), + ShipInventory::new(&items, 400_000, [(ITEM_HULL_PLATE.into(), stock)]), )) .id(); app.world_mut().spawn(( @@ -119,9 +121,10 @@ fn drifting_canister( rotation: Quat, velocity: Vec3, ) -> Entity { + let items = app.world().resource::().clone(); app.world_mut() .spawn(cargo_canister( - CargoCanister::new(ItemType::HullPlate, count), + CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), count), Transform::from_translation(at).with_rotation(rotation), velocity, AssetRef::from("canister.glb#Scene0"), @@ -145,7 +148,7 @@ fn plates(app: &App, ship: Entity) -> u32 { app.world() .get::(ship) .unwrap() - .count(ItemType::HullPlate) + .count(&ITEM_HULL_PLATE.into()) } fn takes(app: &App) -> u32 { @@ -469,10 +472,11 @@ fn a_canister_larger_than_the_free_room_is_refused_whole() { .iter() .any(|pair| pair.canister == canister && !pair.ready)); assert_eq!(plates(&app, ship), 38, "no part of it is taken"); + let items = app.world().resource::().clone(); assert_eq!( app.world() .get::(canister) - .map(CargoCanister::total_mass_g), + .map(|canister| canister.total_mass_g(&items)), Some(40_000) ); @@ -480,7 +484,7 @@ fn a_canister_larger_than_the_free_room_is_refused_whole() { app.world_mut() .get_mut::(ship) .unwrap() - .remove(ItemType::HullPlate, 2); + .remove(&ITEM_HULL_PLATE.into(), 2); for _ in 0..4 { app.update(); } @@ -522,13 +526,15 @@ fn the_door_opens_for_a_canister_that_fits_beside_one_that_does_not() { #[test] fn an_ejected_canister_leaves_without_being_taken_back() { let (mut app, ship, intake) = intake_app(8); + let items = app.world().resource::().clone(); app.world_mut() .entity_mut(intake) .insert(CargoIntakeEjectionQueue(VecDeque::from([ - CargoCanister::new(ItemType::HullPlate, 4), + CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 4), ]))); // The ejection waits for the door, then leaves across the face. + let hull_plate = ITEM_HULL_PLATE.into(); let mut frames = 0; let (canister, birth) = loop { app.update(); @@ -536,10 +542,7 @@ fn an_ejected_canister_leaves_without_being_taken_back() { assert!(frames < 200, "the ejection never left"); if let Some((entity, held, position)) = canisters(&mut app).first() { assert_eq!(door(&app, intake), 1.0, "it left through an open door"); - assert_eq!( - held.stacks().collect::>(), - [(ItemType::HullPlate, 4)] - ); + assert_eq!(held.stacks().collect::>(), [(&hull_plate, 4)]); break (*entity, *position); } }; @@ -565,11 +568,12 @@ fn an_ejected_canister_leaves_without_being_taken_back() { #[test] fn speculative_ejection_contact_is_refused_until_actual_reentry() { let (mut app, ship, intake) = intake_app(8); + let items = app.world().resource::().clone(); app.world_mut().get_mut::(ship).unwrap().0 = Vec3::NEG_Z * 3.0; app.world_mut() .entity_mut(intake) .insert(CargoIntakeEjectionQueue(VecDeque::from([ - CargoCanister::new(ItemType::HullPlate, 4), + CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 4), ]))); let mut frames = 0; @@ -654,7 +658,7 @@ fn speculative_ejection_contact_is_refused_until_actual_reentry() { } /// The item counts held by live canisters and by `intake`'s queue. -fn queued_and_drifting(app: &mut App, intake: Entity) -> BTreeMap { +fn queued_and_drifting(app: &mut App, intake: Entity) -> BTreeMap { let mut held = BTreeMap::new(); let queued = app .world() @@ -663,7 +667,7 @@ fn queued_and_drifting(app: &mut App, intake: Entity) -> BTreeMap let drifting = canisters(app).into_iter().map(|(_, canister, _)| canister); for canister in queued.into_iter().chain(drifting) { for (item, count) in canister.stacks() { - *held.entry(item).or_default() += count; + *held.entry(item.clone()).or_default() += count; } } held @@ -672,10 +676,11 @@ fn queued_and_drifting(app: &mut App, intake: Entity) -> BTreeMap #[test] fn queued_canisters_leave_in_order_through_an_open_door_once_each_birth_point_clears() { let (mut app, ship, intake) = intake_app(8); + let items = app.world().resource::().clone(); let queued = [ - CargoCanister::new(ItemType::Torpedo, 1), - CargoCanister::new(ItemType::RailSlug, 10), - CargoCanister::new(ItemType::PdcRound, 1_000), + CargoCanister::new(&items, &ITEM_TORPEDO.into(), 1), + CargoCanister::new(&items, &ITEM_RAIL_SLUG.into(), 10), + CargoCanister::new(&items, &ITEM_PDC_ROUND.into(), 1_000), ]; // The interface pauses virtual time: a queue confirmed then waits. @@ -732,14 +737,53 @@ fn queued_canisters_leave_in_order_through_an_open_door_once_each_birth_point_cl assert_eq!(plates(&app, ship), 8, "the hold never changed"); } +/// An item only the content catalog defines, with no engine code naming it, +/// is taken by the real avian intake sensor into the hold. +#[test] +fn a_canister_of_a_catalog_only_item_is_taken_into_the_hold() { + let fixture = ItemDesign { + id: ItemDesignId::from("fixture_core"), + name: "Fixture core".into(), + about: "A catalog-only item for tests.".into(), + category: ItemCategoryType::Parts, + mass_g: 5_000, + ask_cr: 60, + bid_cr: 45, + }; + let fixture_id = ItemDesignId::from("fixture_core"); + // One plate, because a stock stack may not be zero. + let (mut app, ship, _intake) = intake_app(1); + app.insert_resource(GameItems::new( + test_items().iter().cloned().chain([fixture]), + )); + let catalog = app.world().resource::().clone(); + let canister = app + .world_mut() + .spawn(cargo_canister( + CargoCanister::new(&catalog, &fixture_id, 3), + Transform::from_xyz(0.0, 0.0, in_front(CAPTURE_GAP.to_engine() * 0.5)), + Vec3::ZERO, + AssetRef::from("canister.glb#Scene0"), + )) + .id(); + + assert!(run_until_gone(&mut app, canister, 600)); + assert_eq!(takes(&app), 1); + let hold = app.world().get::(ship).unwrap(); + assert_eq!(hold.count(&fixture_id), 3); + assert_eq!(hold.used_g(&catalog), 25_000); + assert_eq!(plates(&app, ship), 1); +} + #[test] fn a_despawned_intake_loses_its_waiting_canisters() { let (mut app, ship, intake) = intake_app(8); + let items = app.world().resource::().clone(); app.world_mut() .entity_mut(intake) .insert(CargoIntakeEjectionQueue(VecDeque::from([ - CargoCanister::new(ItemType::HullPlate, 4), - CargoCanister::new(ItemType::HullPlate, 3), + CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 4), + CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 3), ]))); let mut frames = 0; while canisters(&mut app).is_empty() { @@ -756,7 +800,10 @@ fn a_despawned_intake_loses_its_waiting_canisters() { .into_iter() .map(|(_, canister, _)| canister) .collect(); - assert_eq!(left, [CargoCanister::new(ItemType::HullPlate, 4)]); + assert_eq!( + left, + [CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 4)] + ); assert!(app .world_mut() .query::<&CargoIntakeEjectionQueue>() diff --git a/crates/nova_ship/src/sections/frozen.rs b/crates/nova_ship/src/sections/frozen.rs index 80ca5e1bb..b4e832ee0 100644 --- a/crates/nova_ship/src/sections/frozen.rs +++ b/crates/nova_ship/src/sections/frozen.rs @@ -20,7 +20,7 @@ use avian3d::prelude::{ use bevy::prelude::*; use nova_gameplay::prelude::{ AssetRef, Health, HealthZeroMarker, IntegrityDestroyMarker, IntegrityDisabledMarker, - SectionInactiveMarker, TransientFreezeFault, UnsettledBody, + ItemDesignId, SectionInactiveMarker, TransientFreezeFault, UnsettledBody, }; use super::{ @@ -91,6 +91,28 @@ pub struct FrozenSection { fixtures: Vec, } +impl FrozenSection { + /// Every item id the section holds: its reload's, a suspended reload's + /// and each waiting ejection canister's. A save checks them against the + /// loaded item catalog before it writes or opens. + pub fn item_ids(&self) -> impl Iterator { + let suspended = self + .suspended_ammo + .as_ref() + .and_then(|ammo| ammo.reload.as_ref()); + self.reload + .iter() + .chain(suspended) + .map(|reload| &reload.item) + .chain( + self.ejection_queue + .iter() + .flat_map(|queue| queue.0.iter()) + .flat_map(|canister| canister.stacks().map(|(item, _)| item)), + ) + } +} + /// Whether `entity` is a fixture [`freeze_fixture`]/[`spawn_frozen_fixture`] /// know how to handle: a skin plate or a piece of decor. Everything else /// under a section or a plate (render children like `SkinSurfaceMarker` @@ -222,8 +244,8 @@ pub fn freeze_section(world: &World, section: Entity) -> Result(section).is_some(), ammo: world.get::(section).copied(), - suspended_ammo: world.get::(section).copied(), - reload: world.get::(section).copied(), + suspended_ammo: world.get::(section).cloned(), + reload: world.get::(section).cloned(), ejection_queue: world.get::(section).cloned(), turret_stow: world.get::(section).copied(), railgun_charge: world.get::(section).copied(), diff --git a/crates/nova_ship/src/sections/frozen_piece.rs b/crates/nova_ship/src/sections/frozen_piece.rs index 87753a5dd..0e2d6b790 100644 --- a/crates/nova_ship/src/sections/frozen_piece.rs +++ b/crates/nova_ship/src/sections/frozen_piece.rs @@ -843,6 +843,7 @@ mod tests { app.add_plugins(TempEntityPlugin); app.add_plugins(SpaceshipSectionPlugin { render: true }); app.init_resource::(); + app.insert_resource(nova_gameplay::test_support::test_items()); // The destroy path's debris observers need the global rng even in a // headless run. app.add_plugins(EntropyPlugin::::default()); diff --git a/crates/nova_ship/src/sections/integrity.rs b/crates/nova_ship/src/sections/integrity.rs index 6c4b525ca..ee70b3b70 100644 --- a/crates/nova_ship/src/sections/integrity.rs +++ b/crates/nova_ship/src/sections/integrity.rs @@ -172,6 +172,15 @@ pub struct FrozenWreckFragment { sections: Vec, } +impl FrozenWreckFragment { + /// Every item id its sections hold, see [`FrozenSection::item_ids`]. + pub fn item_ids(&self) -> impl Iterator { + self.sections + .iter() + .flat_map(|section| section.state.item_ids()) + } +} + #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] diff --git a/crates/nova_ship/src/sections/railgun_section/firing.rs b/crates/nova_ship/src/sections/railgun_section/firing.rs index 20ac5cf97..a9d067c9a 100644 --- a/crates/nova_ship/src/sections/railgun_section/firing.rs +++ b/crates/nova_ship/src/sections/railgun_section/firing.rs @@ -31,7 +31,7 @@ pub(super) fn insert_railgun_section( if let Some(reload) = config.reload.batch() { commands .entity(entity) - .insert(SectionReload::from_config(reload, ItemType::RailSlug)); + .insert(SectionReload::from_config(reload, ITEM_RAIL_SLUG.into())); } } diff --git a/crates/nova_ship/src/sections/railgun_section/tests.rs b/crates/nova_ship/src/sections/railgun_section/tests.rs index 8d593bb4f..cbe3975c4 100644 --- a/crates/nova_ship/src/sections/railgun_section/tests.rs +++ b/crates/nova_ship/src/sections/railgun_section/tests.rs @@ -2,7 +2,7 @@ //! shell down the hull's own line, and the recoil goes back through the hull //! at the muzzle. -use nova_gameplay::test_support::{settle, unfinished_integrity_physics_app}; +use nova_gameplay::test_support::{settle, test_items, unfinished_integrity_physics_app}; use super::*; use crate::sections::{ @@ -745,9 +745,11 @@ fn a_scripted_railgun_order_waits_for_an_empty_gun_to_reload() { }, Vec3::NEG_Z * 2.0, ); - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(20_000, [(ItemType::RailSlug, 1)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &test_items(), + 20_000, + [(ITEM_RAIL_SLUG.into(), 1)], + )); // Empty the magazine before the order lands. app.world_mut() diff --git a/crates/nova_ship/src/sections/torpedo_section/bay.rs b/crates/nova_ship/src/sections/torpedo_section/bay.rs index 927f4282e..200e817c2 100644 --- a/crates/nova_ship/src/sections/torpedo_section/bay.rs +++ b/crates/nova_ship/src/sections/torpedo_section/bay.rs @@ -168,7 +168,7 @@ pub(super) fn insert_torpedo_section( if let Some(reload) = config.reload.batch() { commands .entity(entity) - .insert(SectionReload::from_config(reload, ItemType::Torpedo)); + .insert(SectionReload::from_config(reload, ITEM_TORPEDO.into())); } } } @@ -793,6 +793,7 @@ pub struct TorpedoBayDoorsMoved { #[cfg(test)] mod tests { use bevy::time::TimeUpdateStrategy; + use nova_gameplay::test_support::test_items; use super::*; @@ -1376,12 +1377,14 @@ mod tests { delay: 0.2, amount: 1, }, - ItemType::Torpedo, + ITEM_TORPEDO.into(), )); let ship = app.world().get::(section).unwrap().parent(); - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(10_000_000, [(ItemType::Torpedo, 10)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &test_items(), + 10_000_000, + [(ITEM_TORPEDO.into(), 10)], + )); for _ in 0..12 { app.update(); @@ -1947,7 +1950,7 @@ mod tests { fn fly(torpedo_type: TorpedoTypeConfig) -> Flight { use nova_gameplay::{ projectile_hooks::ProjectileHooks, - test_support::{settle, unfinished_integrity_physics_app_with}, + test_support::{settle, test_items, unfinished_integrity_physics_app_with}, }; // `unfinished_integrity_physics_app_with` pins a manual clock, so a @@ -1960,6 +1963,7 @@ mod tests { app.add_plugins(crate::physics::prelude::PDControllerPlugin); app.add_plugins(crate::sections::SpaceshipSectionPlugin { render: false }); app.init_resource::(); + app.insert_resource(test_items()); app.finish(); let ship = app diff --git a/crates/nova_ship/src/sections/turret_section/firing.rs b/crates/nova_ship/src/sections/turret_section/firing.rs index 6cab40269..8e10d4247 100644 --- a/crates/nova_ship/src/sections/turret_section/firing.rs +++ b/crates/nova_ship/src/sections/turret_section/firing.rs @@ -421,6 +421,7 @@ pub struct RoundFired { mod tests { use bevy::time::TimeUpdateStrategy; use nova_events::units::prelude::*; + use nova_gameplay::test_support::test_items; use super::{super::config::default_joint_speed, *}; @@ -1020,12 +1021,14 @@ mod tests { delay: 0.2, amount: 3, }, - ItemType::PdcRound, + ITEM_PDC_ROUND.into(), )); let ship = app.world().get::(turret).unwrap().parent(); - app.world_mut() - .entity_mut(ship) - .insert(ShipInventory::new(10_000, [(ItemType::PdcRound, 30)])); + app.world_mut().entity_mut(ship).insert(ShipInventory::new( + &test_items(), + 10_000, + [(ITEM_PDC_ROUND.into(), 30)], + )); for _ in 0..20 { app.update(); diff --git a/crates/nova_ship/src/sections/turret_section/setup.rs b/crates/nova_ship/src/sections/turret_section/setup.rs index bd6a17af9..f60087fe2 100644 --- a/crates/nova_ship/src/sections/turret_section/setup.rs +++ b/crates/nova_ship/src/sections/turret_section/setup.rs @@ -172,7 +172,7 @@ pub(super) fn insert_turret_section( if let Some(reload) = config.reload.batch() { commands .entity(turret) - .insert(SectionReload::from_config(reload, ItemType::PdcRound)); + .insert(SectionReload::from_config(reload, ITEM_PDC_ROUND.into())); } } } diff --git a/crates/nova_wfc/src/check.rs b/crates/nova_wfc/src/check.rs index 467d2ca77..fbaf77c89 100644 --- a/crates/nova_wfc/src/check.rs +++ b/crates/nova_wfc/src/check.rs @@ -15,7 +15,7 @@ use std::collections::HashSet; use bevy::prelude::*; use nova_events::prelude::Meters3; -use nova_gameplay::prelude::AssetRef; +use nova_gameplay::prelude::{AssetRef, GameItems}; use nova_scenario::prelude::{ lint_scenario, BaseScenarioObjectConfig, EventActionConfig, EventConfig, KnownSections, KnownShipDesigns, LintSeverity, ScenarioConfig, ScenarioEventConfig, ScenarioObjectConfig, @@ -227,7 +227,8 @@ pub fn unmated_contacts( /// /// Nothing here re-implements a check: `lint_scenario` is the same function the /// `content lint` gate and the runtime loader run, so what comes back is -/// exactly what the game would refuse. +/// exactly what the game would refuse. The item catalog is empty: a generated +/// hull carries no stock, so any item it names is reported unknown. pub fn lint_errors(scenario: &ScenarioConfig, sections: &GameSections) -> Vec { let known = KnownSections::from_configs(sections.iter()); lint_scenario( @@ -235,6 +236,7 @@ pub fn lint_errors(scenario: &ScenarioConfig, sections: &GameSections) -> Vec SectorManifest { manifest.ships.push(SectorShip { condition: SectorShipConditionType::Derelict, crew: None, - stock: ShipInventoryStock::new([(ItemType::HullPlate, 5)]), + stock: ShipInventoryStock::new([(ITEM_HULL_PLATE.into(), 5)]), credits: 15, ..ship( sector_id(input.coord, "ship", 1), @@ -932,7 +932,7 @@ fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_b ); world.flush(); - let mut spawned: Vec<(String, bool, bool, Vec<(ItemType, u32)>, u32)> = world + let mut spawned: Vec<(String, bool, bool, Vec<(ItemDesignId, u32)>, u32)> = world .query::<( &Name, Has, @@ -946,7 +946,10 @@ fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_b name.to_string(), derelict, lootable, - stock.stacks().collect(), + stock + .stacks() + .map(|(item, count)| (item.clone(), count)) + .collect(), credits.0, ) }) @@ -960,7 +963,7 @@ fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_b "Solmar derelict, former civilian".to_string(), true, true, - vec![(ItemType::HullPlate, 5)], + vec![(ITEM_HULL_PLATE.into(), 5)], 15 ), ] diff --git a/crates/nova_world/src/tests/frozen.rs b/crates/nova_world/src/tests/frozen.rs index 08aa9c96a..617234c74 100644 --- a/crates/nova_world/src/tests/frozen.rs +++ b/crates/nova_world/src/tests/frozen.rs @@ -15,10 +15,10 @@ use nova_events::prelude::{ use nova_gameplay::{ prelude::{ AssetRef, CargoCanister, CargoCanisterRuntimeId, DamageMark, DamageMarks, Health, - IntegrityDestroyMarker, ItemType, PlayerSpaceshipMarker, SectionMarker, ShipCredits, - ShipInventory, ShipInventoryStock, SpaceshipRootMarker, TempEntity, + IntegrityDestroyMarker, PlayerSpaceshipMarker, SectionMarker, ShipCredits, ShipInventory, + ShipInventoryStock, SpaceshipRootMarker, TempEntity, ITEM_IRON_ORE, ITEM_WATER_ICE, }, - test_support::{settle, unfinished_integrity_physics_app}, + test_support::{settle, test_items, unfinished_integrity_physics_app}, }; use nova_scenario::prelude::{ freeze_ship, AsteroidCarvePlugin, AsteroidField, AsteroidMarker, AsteroidPlugin, SectionSource, @@ -629,7 +629,7 @@ fn wreckable(input: SectorGenerationInput) -> SectorManifest { aft.position = Vec3::Z; aft.source = SectionSource::prototype(TEST_TURRET_SECTION_ID); fought.design.sections.push(aft); - fought.stock = ShipInventoryStock::new([(ItemType::IronOre, 10)]); + fought.stock = ShipInventoryStock::new([(ITEM_IRON_ORE.into(), 10)]); let held = ship( sector_id(input.coord, "ship", 1), centre + Meters3::new(0.0, 0.0, quarter), @@ -652,6 +652,7 @@ fn ship_app() -> App { AsteroidCarvePlugin { render: false }, )); app.insert_resource(test_sections()); + app.insert_resource(test_items()); app.init_resource::(); app.init_resource::(); app.add_observer( @@ -767,7 +768,7 @@ fn wreck_home(app: &mut App) -> (Entity, Entity) { world .get_mut::(fought) .expect("a ship has a hold") - .remove(ItemType::IronOre, 7); + .remove(&ITEM_IRON_ORE.into(), 7); world.entity_mut(fought).insert(ShipCredits(0)); settle(app); (rock, fought) @@ -908,7 +909,7 @@ fn a_snapshot_of_the_live_world_reads_back_as_the_same_world() { let id = world .resource_mut::() .mint(); - let contents = CargoCanister::new(ItemType::IronOre, 4); + let contents = CargoCanister::new(&test_items(), &ITEM_IRON_ORE.into(), 4); world.spawn(( cargo_canister( contents.clone(), @@ -1180,8 +1181,9 @@ fn a_loose_canister_freezes_with_the_cell_it_drifts_in() { let id = world .resource_mut::() .mint(); - let mut contents = CargoCanister::new(ItemType::IronOre, 4); - contents.add(ItemType::WaterIce, 2); + let items = test_items(); + let mut contents = CargoCanister::new(&items, &ITEM_IRON_ORE.into(), 4); + contents.add(&items, &ITEM_WATER_ICE.into(), 2); let at = Transform::from_translation(home.centre(edge).to_engine() + Vec3::Y * 40.0); let canister = world .spawn(( diff --git a/crates/nova_world_base/Cargo.toml b/crates/nova_world_base/Cargo.toml index 60b5c77f5..b8af52105 100644 --- a/crates/nova_world_base/Cargo.toml +++ b/crates/nova_world_base/Cargo.toml @@ -53,6 +53,9 @@ rustix = { version = "1", features = ["fs"] } [dev-dependencies] # Scratch worlds roots for the save tests. tempfile = "3" +# `test_items`, the fixture item catalog the generator's tests and fixtures +# arm over. +nova_gameplay = { path = "../nova_gameplay", features = ["test-support"] } [lints] workspace = true @@ -60,6 +63,6 @@ workspace = true [features] # `test_support`: an armed open world whose saves run the production save # path, for the menu's leave tests. Dev-dependencies only. -test-support = [] +test-support = ["nova_gameplay/test-support"] # Debug-build extras, fanned out so the whole tree resolves ONE bevy variant. debug = ["bevy/track_location", "nova_world/debug", "nova_scenario/debug"] diff --git a/crates/nova_world_base/src/clusters.rs b/crates/nova_world_base/src/clusters.rs index 165426690..821d4bdbd 100644 --- a/crates/nova_world_base/src/clusters.rs +++ b/crates/nova_world_base/src/clusters.rs @@ -67,7 +67,8 @@ use bevy::prelude::{Quat, Vec3}; use nova_events::prelude::{Meters, Meters3, MetersPerSecond3, MetersPerSecondSquared}; use nova_gameplay::prelude::{ - circular_orbit_speed, unit_sphere_point, Fnv32, GravitySettings, GravityWell, SeedStream, + circular_orbit_speed, unit_sphere_point, Fnv32, GameItems, GravitySettings, GravityWell, + SeedStream, }; use nova_scenario::prelude::{ asteroid_seed_from_id, PlanetConfig, PlanetType, ASTEROID_GEOMETRIC_FACTOR_MAX, KIND_CARBON, @@ -363,7 +364,13 @@ pub fn sector_clusters( world: &NovaLayeredWorld, input: SectorGenerationInput, ) -> Result { - Ok(plan_sector(&EnvironmentFields::new(input.seed), world.parts(), input)?.summary()) + Ok(plan_sector( + &EnvironmentFields::new(input.seed), + world.parts(), + world.items(), + input, + )? + .summary()) } /// Refuse a geometry this policy cannot fill. @@ -1535,6 +1542,7 @@ fn halo_nodes(coord: SectorCoord, edge: Meters) -> Vec<[i32; 3]> { pub(crate) fn plan_sector( fields: &EnvironmentFields, parts: &ShipPartSnapshot, + items: &GameItems, input: SectorGenerationInput, ) -> Result { let coord = input.coord; @@ -1593,6 +1601,7 @@ pub(crate) fn plan_sector( let mut planned = candidate("hull", index, BodySource::Hull(node, index), &hull.hull); let ship = plan_ship( parts, + items, &civilizations, input.seed, HullSlot { @@ -1867,8 +1876,13 @@ mod tests { desired_sectors(SectorCoord::ORIGIN, config.active_radius) .into_iter() .map(|coord| { - let plan = plan_sector(&fields, config.generator.parts(), config.input(coord)) - .unwrap_or_else(|fault| panic!("{coord}: {fault}")); + let plan = plan_sector( + &fields, + config.generator.parts(), + config.generator.items(), + config.input(coord), + ) + .unwrap_or_else(|fault| panic!("{coord}: {fault}")); (coord, plan) }) .collect() @@ -1925,6 +1939,7 @@ mod tests { let fault = plan_sector( &EnvironmentFields::new(SEED), &unbuildable, + config().generator.items(), config().input(owner), ) .expect_err("a hull no layout fits must fail its cell"); @@ -2226,8 +2241,13 @@ mod tests { } } for coord in coords { - let plan = plan_sector(&fields, config.generator.parts(), config.input(coord)) - .unwrap_or_else(|fault| panic!("{seed} {coord}: {fault}")); + let plan = plan_sector( + &fields, + config.generator.parts(), + config.generator.items(), + config.input(coord), + ) + .unwrap_or_else(|fault| panic!("{seed} {coord}: {fault}")); alone += (plan.bodies.iter()) .filter(|body| body.skipped == Some(SkipType::Companion)) .count(); @@ -2471,6 +2491,7 @@ mod tests { let plan = plan_sector( &fields, config.generator.parts(), + config.generator.items(), config.input(SectorCoord::new(x, 0, z)), ) .unwrap_or_else(|fault| panic!("[{x}, 0, {z}]: {fault}")); @@ -2552,7 +2573,13 @@ mod tests { .unwrap() .rocks[1] .position; - let mut plan = plan_sector(&fields, world.generator.parts(), world.input(coord)).unwrap(); + let mut plan = plan_sector( + &fields, + world.generator.parts(), + world.generator.items(), + world.input(coord), + ) + .unwrap(); let escort_index = plan .bodies .iter() @@ -2627,8 +2654,20 @@ mod tests { .unwrap() .rocks[1] .position; - let first = plan_sector(&fields, world.generator.parts(), world.input(coord)).unwrap(); - let second = plan_sector(&fields, world.generator.parts(), world.input(coord)).unwrap(); + let first = plan_sector( + &fields, + world.generator.parts(), + world.generator.items(), + world.input(coord), + ) + .unwrap(); + let second = plan_sector( + &fields, + world.generator.parts(), + world.generator.items(), + world.input(coord), + ) + .unwrap(); let id = "sector_2_0_1_cluster_1_0_1_rock_1"; let placed = first.bodies.iter().find(|body| body.id == id).unwrap(); assert!(placed.skipped.is_none()); @@ -2738,8 +2777,13 @@ mod tests { world.seed = seed; let fields = EnvironmentFields::new(seed); for coord in desired_sectors(SectorCoord::ORIGIN, world.active_radius) { - let plan = - plan_sector(&fields, world.generator.parts(), world.input(coord)).unwrap(); + let plan = plan_sector( + &fields, + world.generator.parts(), + world.generator.items(), + world.input(coord), + ) + .unwrap(); let manifest = plan.manifest().unwrap(); let wells: Vec<_> = plan .clusters diff --git a/crates/nova_world_base/src/layered.rs b/crates/nova_world_base/src/layered.rs index d98056e2b..fa63af5a2 100644 --- a/crates/nova_world_base/src/layered.rs +++ b/crates/nova_world_base/src/layered.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use nova_assets::prelude::{ContentCatalogDigest, LoadedSectionPacks}; use nova_events::prelude::Meters; +use nova_gameplay::prelude::GameItems; use nova_ship::prelude::GameStyles; use nova_world::prelude::*; @@ -51,15 +52,18 @@ const SECTOR_EDGE_MAX: Meters = Meters(128_000.0); /// `PlanetType` - so there is no list a caller could leave empty or fill with /// an id the game does not ship. The ship parts it pins are the snapshot of /// the loaded catalog it was built from, with that catalog's digest; every -/// ship it plans is laid out from them. +/// ship it plans is laid out from them. Generated stock reads masses from +/// the pinned item catalog. #[derive(Clone, Debug)] pub struct NovaLayeredWorld { parts: Arc, catalog: ContentCatalogDigest, + items: Arc, } /// Equal when both pin one catalog and one ship-part snapshot. Compares two /// digests, never the parts, so the arming check stays cheap every frame. +/// The catalog digest also covers the items. impl PartialEq for NovaLayeredWorld { fn eq(&self, other: &Self) -> bool { self.catalog == other.catalog && self.parts.content_hash() == other.parts.content_hash() @@ -81,6 +85,7 @@ impl NovaLayeredWorld { pub fn from_loaded( loaded: &LoadedSectionPacks, styles: &GameStyles, + items: &GameItems, ) -> Result> { let packs: Vec = loaded .packs @@ -103,6 +108,7 @@ impl NovaLayeredWorld { Ok(parts) if unstyled.is_empty() => Ok(Self { parts: Arc::new(parts), catalog: loaded.digest, + items: Arc::new(items.clone()), }), Ok(_) => Err(unstyled), Err(mut faults) => { @@ -121,6 +127,11 @@ impl NovaLayeredWorld { pub fn catalog(&self) -> ContentCatalogDigest { self.catalog } + + /// The item catalog this world was armed with. + pub fn items(&self) -> &GameItems { + &self.items + } } impl SectorGenerator for NovaLayeredWorld { @@ -142,7 +153,13 @@ impl SectorGenerator for NovaLayeredWorld { } fn generate(&self, input: SectorGenerationInput) -> Result { - plan_sector(&EnvironmentFields::new(input.seed), &self.parts, input)?.manifest() + plan_sector( + &EnvironmentFields::new(input.seed), + &self.parts, + &self.items, + input, + )? + .manifest() } } diff --git a/crates/nova_world_base/src/lib.rs b/crates/nova_world_base/src/lib.rs index 2fece17b7..158f1cc47 100644 --- a/crates/nova_world_base/src/lib.rs +++ b/crates/nova_world_base/src/lib.rs @@ -37,7 +37,7 @@ use bevy::prelude::*; use nova_assets::prelude::LoadedSectionPacks; use nova_events::prelude::Meters; -use nova_gameplay::prelude::PlayerSpaceshipMarker; +use nova_gameplay::prelude::{GameItems, PlayerSpaceshipMarker}; use nova_scenario::prelude::{CurrentScenario, ScenarioRole}; use nova_ship::prelude::GameStyles; use nova_world::prelude::*; @@ -59,9 +59,9 @@ mod tests; #[cfg(not(target_arch = "wasm32"))] pub use crate::save::{ - create_world, delete_world, list_worlds, open_world, resume_world, world_slug, write_world, - FrozenTransient, FrozenTransientType, ResumedWorld, SaveReason, SavedPlayer, WorldFolder, - WorldListing, WorldLock, WorldRefusal, WorldResumeProgress, WorldResumeRefused, + check_world, create_world, delete_world, list_worlds, open_world, resume_world, world_slug, + write_world, FrozenTransient, FrozenTransientType, ResumedWorld, SaveReason, SavedPlayer, + WorldFolder, WorldListing, WorldLock, WorldRefusal, WorldResumeProgress, WorldResumeRefused, WorldSaveHeader, WorldSaveSession, WorldSaveState, WorldSaveStatus, WORLD_RESUME_SECONDS_MAX, WORLD_SAVE_FORMAT, }; @@ -92,10 +92,10 @@ pub use crate::{ pub mod prelude { #[cfg(not(target_arch = "wasm32"))] pub use super::{ - create_world, delete_world, list_worlds, open_world, resume_world, world_slug, write_world, - FrozenTransient, FrozenTransientType, ResumedWorld, SaveReason, SavedPlayer, WorldFolder, - WorldListing, WorldLock, WorldRefusal, WorldResumeProgress, WorldResumeRefused, - WorldSaveHeader, WorldSaveSession, WorldSaveState, WorldSaveStatus, + check_world, create_world, delete_world, list_worlds, open_world, resume_world, world_slug, + write_world, FrozenTransient, FrozenTransientType, ResumedWorld, SaveReason, SavedPlayer, + WorldFolder, WorldListing, WorldLock, WorldRefusal, WorldResumeProgress, + WorldResumeRefused, WorldSaveHeader, WorldSaveSession, WorldSaveState, WorldSaveStatus, WORLD_RESUME_SECONDS_MAX, WORLD_SAVE_FORMAT, }; pub use super::{ @@ -291,6 +291,12 @@ fn sync_open_world(world: &mut World) { merge refused the catalog and logged why" ); }; + let Some(items) = world.get_resource::() else { + panic!( + "nova_world_base: an OpenWorld scenario is live with no GameItems; the content \ + merge refused the catalog and logged why" + ); + }; let armed = world.get_resource::>(); let generator = match armed { Some(armed) if armed.generator.catalog() != loaded.digest => panic!( @@ -300,7 +306,7 @@ fn sync_open_world(world: &mut World) { loaded.digest.0 ), Some(armed) => armed.generator.clone(), - None => NovaLayeredWorld::from_loaded(loaded, styles).unwrap_or_else(|faults| { + None => NovaLayeredWorld::from_loaded(loaded, styles, items).unwrap_or_else(|faults| { panic!( "nova_world_base: the loaded content does not arm the open world:\n {}", faults diff --git a/crates/nova_world_base/src/save/mod.rs b/crates/nova_world_base/src/save/mod.rs index fdfd7ca9c..9d20e3a46 100644 --- a/crates/nova_world_base/src/save/mod.rs +++ b/crates/nova_world_base/src/save/mod.rs @@ -31,7 +31,8 @@ use bevy::prelude::*; use nova_assets::{prelude::LoadedSectionPacks, storage::write_atomic}; use nova_events::prelude::EntityId; use nova_gameplay::prelude::{ - CargoCanisterRuntimeId, SavedBodyRef, SavedOwner, SavedSectionRef, SavedTargetRef, + CargoCanisterRuntimeId, GameItems, ItemDesignId, SavedBodyRef, SavedOwner, SavedSectionRef, + SavedTargetRef, }; use nova_scenario::prelude::FrozenShip; use nova_ship::prelude::{CameraView, SavedTorpedoTarget}; @@ -56,7 +57,7 @@ pub use transients::{ /// Bump it with any change to the save layout, or to what the generator /// builds from a seed: an old save holds only the sectors the player changed /// and regenerates the rest, so another generator would change it silently. -pub const WORLD_SAVE_FORMAT: u32 = 1; +pub const WORLD_SAVE_FORMAT: u32 = 2; /// The most characters a world name has, after trimming. const WORLD_NAME_MAX: usize = 32; @@ -281,20 +282,58 @@ pub fn create_world(root: &Path, name: &str) -> Result<(WorldFolder, WorldLock), /// Lock the world `slug` under `root` and read its save. /// -/// Refuses a world another game has open, a header of another format or -/// catalog, and a state file that is missing, corrupt, of another generation -/// or holding a camera view or a transient that cannot open, and writes -/// nothing then. On success it removes the state and temp files the header -/// does not name. +/// Refuses a world another game has open and every save [`check_world`] +/// refuses, and writes nothing then. On success it removes the state and temp +/// files the header does not name. pub fn open_world( root: &Path, slug: &str, loaded: &LoadedSectionPacks, + items: &GameItems, ) -> Result<(WorldFolder, WorldLock, WorldSaveHeader, WorldSaveState), WorldRefusal> { check_slug(slug)?; let path = root.join(slug); let lock = lock_world(&path)?; let header = read_header(&path, loaded)?; + let state = read_state(&path, &header, items)?; + sweep_orphans(&path, &state_file(header.generation)); + Ok(( + WorldFolder { + path, + slug: slug.to_string(), + }, + lock, + header, + state, + )) +} + +/// Read the save of the world `slug` under `root` and run every check +/// [`open_world`] runs, without its lock or its cleanup. +/// +/// Refuses a header of another format or catalog, and a state file that is +/// missing, corrupt, of another generation, holding a camera view or a +/// transient that cannot open, an id [`check_saved_ids`] refuses, or an item +/// `items` does not hold. Reads the whole state, so run it off the main +/// thread; the Load list does, before the player can pick the world. +pub fn check_world( + root: &Path, + slug: &str, + loaded: &LoadedSectionPacks, + items: &GameItems, +) -> Result<(), WorldRefusal> { + check_slug(slug)?; + let path = root.join(slug); + let header = read_header(&path, loaded)?; + read_state(&path, &header, items).map(drop) +} + +/// Read and check the state file `header` names in the world folder `path`. +fn read_state( + path: &Path, + header: &WorldSaveHeader, + items: &GameItems, +) -> Result { let state_name = state_file(header.generation); let text = std::fs::read_to_string(path.join(&state_name)) .map_err(|e| WorldRefusal::Unreadable(format!("{state_name}: {e}")))?; @@ -318,16 +357,51 @@ pub fn open_world( } check_saved_ids(&state) .map_err(|fault| WorldRefusal::Unreadable(format!("{state_name}: {fault}")))?; - sweep_orphans(&path, &state_name); - Ok(( - WorldFolder { - path, - slug: slug.to_string(), - }, - lock, - header, - state, - )) + check_saved_items(&state, items) + .map_err(|fault| WorldRefusal::Unreadable(format!("{state_name}: {fault}")))?; + Ok(state) +} + +/// Every item id the saved state holds is in `items`: the player's, every +/// ledger body's and every held-back ship's stock. A save checks before it +/// writes; Load checks after the header's catalog digest has matched. A +/// changed or missing mod normally fails at that earlier digest check. +fn check_saved_items(state: &WorldSaveState, items: &GameItems) -> Result<(), String> { + let unknown = |mut ids: &mut dyn Iterator| { + Iterator::find(&mut ids, |item| items.get(item).is_none()).cloned() + }; + if let Some(item) = unknown(&mut state.player.ship.item_ids()) { + return Err(format!( + "unknown item '{item}' held by the player ship '{}'", + state.player.id.0 + )); + } + for (coord, record) in state.sectors.iter() { + for body in record.bodies() { + let found = match body.body() { + FrozenBodyType::Asteroid(rock) => unknown(&mut rock.item_ids()), + FrozenBodyType::Ship(ship) => unknown(&mut ship.item_ids()), + FrozenBodyType::PendingShip(ship) => { + unknown(&mut ship.stock.stacks().map(|(item, _)| item)) + } + FrozenBodyType::Canister(canister) => unknown(&mut canister.item_ids()), + FrozenBodyType::WreckFragment(fragment) => unknown(&mut fragment.item_ids()), + FrozenBodyType::OreDrop(drop) => unknown(&mut drop.item_ids()), + }; + let Some(item) = found else { + continue; + }; + let owner = match (body.id(), body.body()) { + (Some(id), _) => format!("'{id}'"), + (None, FrozenBodyType::Canister(canister)) => { + format!("the canister {}", canister.id().0) + } + (None, _) => format!("a body in sector {coord}"), + }; + return Err(format!("unknown item '{item}' held by {owner}")); + } + } + Ok(()) } /// Why the ids of a saved state cannot open. diff --git a/crates/nova_world_base/src/save/session.rs b/crates/nova_world_base/src/save/session.rs index 50cc62edf..1ad5b468c 100644 --- a/crates/nova_world_base/src/save/session.rs +++ b/crates/nova_world_base/src/save/session.rs @@ -30,7 +30,7 @@ use nova_world::prelude::{ }; use super::{ - check_saved_ids, + check_saved_ids, check_saved_items, transients::{freeze_transients, hold_resumed_transients}, write_world, SavedIdFault, SavedPlayer, WorldFolder, WorldLock, WorldSaveHeader, WorldSaveState, WORLD_SAVE_FORMAT, @@ -442,6 +442,12 @@ pub(crate) fn snapshot_world(world: &mut World) { .iter() .map(|pack| pack.id.clone()) .collect(); + // The catalog the world armed over: the arming check refuses any other. + let items = world + .resource::>() + .generator + .items() + .clone(); let canister_ids_next = world .resource::() .next_unminted() @@ -479,13 +485,18 @@ pub(crate) fn snapshot_world(world: &mut World) { canister_ids_next, transients, }; - if let Err(fault) = check_saved_ids(&state) { - let error = match fault { + let checked = check_saved_ids(&state) + .map_err(|fault| match fault { SavedIdFault::Duplicate(id) => SectorSnapshotError::DuplicateId { id }, fault => SectorSnapshotError::InvalidSavedState { reason: fault.to_string(), }, - }; + }) + .and_then(|()| { + check_saved_items(&state, &items) + .map_err(|reason| SectorSnapshotError::InvalidSavedState { reason }) + }); + if let Err(error) = checked { session.wanted = None; session.settling_frames = 0; session.status = WorldSaveStatus::Failed(error.to_string()); diff --git a/crates/nova_world_base/src/save/tests.rs b/crates/nova_world_base/src/save/tests.rs index 0b717db49..77c203618 100644 --- a/crates/nova_world_base/src/save/tests.rs +++ b/crates/nova_world_base/src/save/tests.rs @@ -5,35 +5,44 @@ use std::{path::Path, time::Duration}; use avian3d::prelude::{LinearVelocity, RigidBody, Rotation}; use bevy::prelude::*; use nova_assets::prelude::{ContentCatalogDigest, LoadedSectionPack, LoadedSectionPacks}; -use nova_events::prelude::EntityId; +use nova_events::prelude::{EntityId, Meters, Meters3, MetersPerSecond3}; use nova_gameplay::prelude::{ - nova_blast, resumed_lifetime, AssetRef, CargoCanister, CargoCanisterIdAllocator, + nova_blast, resumed_lifetime, Allegiance, AssetRef, CargoCanister, CargoCanisterIdAllocator, CargoCanisterRuntimeId, ClockFreeze, DamageMarks, DamageType, FreezeOwner, FrozenRoundFlight, - Health, IntegrityDestroyMarker, ItemType, PlayerSpaceshipMarker, PointRotationOutput, - ProjectileDamage, ProjectileOwner, RailgunSlugProjectileMarker, RoundRake, SavedBodyRef, - SavedLifetime, SavedOwner, SavedSectionRef, SavedTargetRef, ShipCredits, ShipInventory, - SpaceshipRootMarker, TorpedoProjectileMarker, TurretBulletProjectileMarker, + GameItems, Health, IntegrityDestroyMarker, ItemCategoryType, ItemDesign, ItemDesignId, + PlayerSpaceshipMarker, PointRotationOutput, ProjectileDamage, ProjectileOwner, + RailgunSlugProjectileMarker, RoundRake, SavedBodyRef, SavedLifetime, SavedOwner, + SavedSectionRef, SavedTargetRef, SectionMarker, ShipCredits, ShipInventory, ShipInventoryStock, + SpaceshipRootMarker, TorpedoProjectileMarker, TurretBulletProjectileMarker, ITEM_IRON_ORE, }; use nova_scenario::prelude::{ - freeze_ship, spaceship_scenario_object, CurrentScenario, SpaceshipConfig, + freeze_asteroid, freeze_ore_drop, freeze_ship, spaceship_scenario_object, AsteroidKind, + AsteroidKindId, AsteroidMarker, AsteroidRadius, AsteroidSeed, AsteroidTexture, CurrentScenario, + MinedCanisterQueue, MinedOre, MinedOreDrop, ShipDesign, SpaceshipConfig, }; use nova_ship::prelude::{ - cargo_canister, freeze_canister, thaw_round, thaw_torpedo, CameraView, ChaseZoom, FrozenRound, - FrozenTorpedo, RoundSourceType, SavedTorpedoTarget, SpaceshipCameraController, - SpaceshipCameraInputMarker, SpaceshipCameraNormalInputMarker, TorpedoArming, - TorpedoControllerMarker, TorpedoSectionConfig, TorpedoTargetChosen, TorpedoTargetEntity, - TorpedoTargetPosition, TorpedoWeave, + cargo_canister, freeze_canister, freeze_wreck_fragment, thaw_round, thaw_torpedo, + BaseSectionConfig, BodyRadius, CameraView, CargoIntakeEjectionQueue, ChaseZoom, DestroySound, + FrozenRound, FrozenTorpedo, HullSectionConfig, LockSignature, RoundSourceType, + SavedTorpedoTarget, SectionAnimations, SectionBuildConfig, SectionConfig, SectionKind, + SectionReload, SectionReloadConfig, ShipWreckFragmentMarker, SpaceshipCameraController, + SpaceshipCameraInputMarker, SpaceshipCameraNormalInputMarker, SuspendedSectionAmmo, + TorpedoArming, TorpedoControllerMarker, TorpedoSectionConfig, TorpedoTargetChosen, + TorpedoTargetEntity, TorpedoTargetPosition, TorpedoWeave, }; use nova_world::{ - prelude::{CurrentSector, FrozenSectors, SectorCoord, WorldConfig}, + prelude::{ + CivilizationId, CurrentSector, FrozenBodyType, FrozenSectors, SectorCoord, SectorShip, + SectorShipConditionType, SectorShipCrew, ShipRoleType, WorldConfig, + }, SectorRoot, }; use super::{ - create_world, delete_world, list_worlds, open_world, restore_resumed_world, resume_world, - write_world, FrozenTransient, FrozenTransientType, ResumedTransients, SavedPlayer, - WorldRefusal, WorldResumeProgress, WorldResumeRefused, WorldSaveHeader, WorldSaveSession, - WorldSaveState, WorldSaveStatus, WORLD_SAVE_FORMAT, + check_world, create_world, delete_world, list_worlds, open_world, restore_resumed_world, + resume_world, write_world, FrozenTransient, FrozenTransientType, ResumedTransients, + SavedPlayer, WorldRefusal, WorldResumeProgress, WorldResumeRefused, WorldSaveHeader, + WorldSaveSession, WorldSaveState, WorldSaveStatus, WORLD_SAVE_FORMAT, }; use crate::NovaLayeredWorld; @@ -50,6 +59,11 @@ fn catalog(digest: u64) -> LoadedSectionPacks { } } +/// The item catalog every opened world is checked against. +fn items() -> GameItems { + nova_gameplay::test_support::test_items() +} + /// The header of generation `generation` of the world `name`, saved with the /// catalog `catalog(7)`. fn header(name: &str, generation: u64) -> WorldSaveHeader { @@ -161,11 +175,11 @@ fn a_world_another_game_holds_open_is_refused() { write_world(&folder, &header("Held", 1), &state(1, 750)).unwrap(); assert_eq!( - open_world(root.path(), "held", &catalog(7)).unwrap_err(), + open_world(root.path(), "held", &catalog(7), &items()).unwrap_err(), WorldRefusal::Locked ); drop(lock); - assert!(open_world(root.path(), "held", &catalog(7)).is_ok()); + assert!(open_world(root.path(), "held", &catalog(7), &items()).is_ok()); } /// The list shows every folder with the reason it cannot load: another @@ -221,15 +235,15 @@ fn the_list_shows_why_each_refused_world_cannot_load() { ); assert!(matches!( - open_world(root.path(), "good", &catalog(8)), + open_world(root.path(), "good", &catalog(8), &items()), Err(WorldRefusal::Catalog { .. }) )); assert!(matches!( - open_world(root.path(), "other-format", &catalog(7)), + open_world(root.path(), "other-format", &catalog(7), &items()), Err(WorldRefusal::Format { found: 99 }) )); assert!(matches!( - open_world(root.path(), "../good", &catalog(7)), + open_world(root.path(), "../good", &catalog(7), &items()), Err(WorldRefusal::InvalidName(_)) )); let good = list_worlds(root.path(), &catalog(7)).unwrap(); @@ -266,7 +280,8 @@ fn a_written_world_opens_as_it_was_saved() { ); drop(lock); - let (opened, _lock, read, saved) = open_world(root.path(), "round-trip", &catalog(7)).unwrap(); + let (opened, _lock, read, saved) = + open_world(root.path(), "round-trip", &catalog(7), &items()).unwrap(); assert_eq!(opened, folder); assert_eq!(read, header("Round Trip", 2)); assert_eq!(saved.generation, 2); @@ -293,7 +308,7 @@ fn a_failed_write_leaves_the_last_good_save() { assert!(write_world(&folder, &header("Kept", 2), &state(2, 990)).is_err()); drop(lock); - let (_, _lock, read, saved) = open_world(root.path(), "kept", &catalog(7)).unwrap(); + let (_, _lock, read, saved) = open_world(root.path(), "kept", &catalog(7), &items()).unwrap(); assert_eq!(read.generation, 1); assert_eq!( ron::to_string(&saved).unwrap(), @@ -313,7 +328,7 @@ fn opening_a_world_removes_the_files_its_header_does_not_name() { std::fs::write(folder.path.join(".world.ron.123.tmp"), "half").unwrap(); std::fs::write(folder.path.join("notes.txt"), "mine").unwrap(); - open_world(root.path(), "swept", &catalog(7)).unwrap(); + open_world(root.path(), "swept", &catalog(7), &items()).unwrap(); assert_eq!( files(&folder.path), ["notes.txt", "state.1.ron", "world.lock", "world.ron"] @@ -525,7 +540,8 @@ fn the_first_frame_and_a_leave_each_write_a_save() { ); let digest = world.resource::().digest.0; world.remove_resource::(); - let (_, _lock, header, state) = open_world(root.path(), "session", &catalog(digest)).unwrap(); + let (_, _lock, header, state) = + open_world(root.path(), "session", &catalog(digest), &items()).unwrap(); assert_eq!((header.generation, header.credits), (2, 410)); assert_eq!(state.player.id.0, "player"); } @@ -579,7 +595,8 @@ fn a_world_that_disarms_and_rearms_never_overwrites_its_save() { )); let digest = world.resource::().digest.0; world.remove_resource::(); - let (_, _lock, header, _) = open_world(root.path(), "session", &catalog(digest)).unwrap(); + let (_, _lock, header, _) = + open_world(root.path(), "session", &catalog(digest), &items()).unwrap(); assert_eq!((header.generation, header.credits), (1, 300)); } } @@ -710,7 +727,8 @@ fn a_save_waits_visibly_until_the_player_camera_is_ready() { ); let digest = world.resource::().digest.0; world.remove_resource::(); - let (_, _lock, _, saved) = open_world(root.path(), "session", &catalog(digest)).unwrap(); + let (_, _lock, _, saved) = + open_world(root.path(), "session", &catalog(digest), &items()).unwrap(); assert_eq!(saved.player.view, expected_view); } @@ -784,7 +802,8 @@ fn load(root: &Path, digest: u64) -> World { .remove_resource::>() .unwrap(); config.active_radius = 0; - let (folder, lock, header, state) = open_world(root, "session", &catalog(digest)).unwrap(); + let (folder, lock, header, state) = + open_world(root, "session", &catalog(digest), &items()).unwrap(); resume_world(&mut world, folder, lock, &header, state); world.insert_resource(config); restore_resumed_world(&mut world); @@ -956,7 +975,8 @@ fn a_load_saves_nothing_until_its_transients_are_back() { ); let digest = world.resource::().digest.0; world.remove_resource::(); - let (_, _lock, header, state) = open_world(root.path(), "session", &catalog(digest)).unwrap(); + let (_, _lock, header, state) = + open_world(root.path(), "session", &catalog(digest), &items()).unwrap(); assert_eq!(header.generation, 2); assert_eq!(state.transients.len(), 1, "the new save keeps the round"); } @@ -1035,7 +1055,8 @@ fn a_save_waits_out_a_live_blast_and_a_raking_slug() { ); let digest = world.resource::().digest.0; world.remove_resource::(); - let (_, _lock, _, state) = open_world(root.path(), "session", &catalog(digest)).unwrap(); + let (_, _lock, _, state) = + open_world(root.path(), "session", &catalog(digest), &items()).unwrap(); let [FrozenTransient { body: FrozenTransientType::Round(round), .. @@ -1749,7 +1770,7 @@ fn a_world_with_a_duplicate_id_is_refused_on_open() { }; write_world(&folder, &header("Ids", generation), &state).unwrap(); assert_eq!( - open_world(root.path(), "ids", &catalog(7)).map(|_| ()), + open_world(root.path(), "ids", &catalog(7), &items()).map(|_| ()), Err(WorldRefusal::Unreadable(format!( "state.{generation}.ron: {expected}" ))), @@ -1771,7 +1792,7 @@ fn a_world_with_a_duplicate_id_is_refused_on_open() { }; write_world(&folder, &header("Ids", frozen_generation), &frozen_target).unwrap(); { - let (_, _lock, _, saved) = open_world(root.path(), "ids", &catalog(7)).unwrap(); + let (_, _lock, _, saved) = open_world(root.path(), "ids", &catalog(7), &items()).unwrap(); assert_eq!(saved.transients.len(), 1); } @@ -1794,7 +1815,7 @@ fn a_world_with_a_duplicate_id_is_refused_on_open() { ) .unwrap(); { - let (_, _lock, _, saved) = open_world(root.path(), "ids", &catalog(7)).unwrap(); + let (_, _lock, _, saved) = open_world(root.path(), "ids", &catalog(7), &items()).unwrap(); assert_eq!(saved.transients.len(), 1); } @@ -1808,7 +1829,7 @@ fn a_world_with_a_duplicate_id_is_refused_on_open() { ..state(final_generation, 750) }; write_world(&folder, &header("Ids", final_generation), &state).unwrap(); - let (_, _lock, _, saved) = open_world(root.path(), "ids", &catalog(7)).unwrap(); + let (_, _lock, _, saved) = open_world(root.path(), "ids", &catalog(7), &items()).unwrap(); assert_eq!(saved.transients.len(), 2); assert_eq!(saved.sectors.len(), 1); } @@ -1817,6 +1838,7 @@ fn a_world_with_a_duplicate_id_is_refused_on_open() { /// canister ids, built through the record's RON form like [`ledger`]. fn canister_ledger(cells: &[(SectorCoord, &[u64])]) -> FrozenSectors { let mut world = World::new(); + let items = nova_gameplay::test_support::test_items(); let transform = ron::to_string(&Transform::IDENTITY).unwrap(); let cells: Vec = cells .iter() @@ -1827,7 +1849,7 @@ fn canister_ledger(cells: &[(SectorCoord, &[u64])]) -> FrozenSectors { let canister = world .spawn(( cargo_canister( - CargoCanister::new(ItemType::IronOre, 1), + CargoCanister::new(&items, &ItemDesignId::from(ITEM_IRON_ORE), 1), Transform::IDENTITY, Vec3::ZERO, AssetRef::from("canister.glb#Scene0"), @@ -1877,7 +1899,7 @@ fn a_world_with_a_duplicate_or_unminted_canister_is_refused_on_open() { assert_eq!(state.canister_ids_next, 12); write_world(&folder, &header("Canisters", generation), &state).unwrap(); assert_eq!( - open_world(root.path(), "canisters", &catalog(7)).map(|_| ()), + open_world(root.path(), "canisters", &catalog(7), &items()).map(|_| ()), Err(WorldRefusal::Unreadable(format!( "state.{generation}.ron: {expected}" ))), @@ -1890,7 +1912,7 @@ fn a_world_with_a_duplicate_or_unminted_canister_is_refused_on_open() { ..state(3, 750) }; write_world(&folder, &header("Canisters", 3), &state).unwrap(); - let (_, _lock, _, saved) = open_world(root.path(), "canisters", &catalog(7)).unwrap(); + let (_, _lock, _, saved) = open_world(root.path(), "canisters", &catalog(7), &items()).unwrap(); assert_eq!(saved.sectors.len(), 1); } @@ -1938,3 +1960,388 @@ fn a_save_with_a_duplicate_or_unminted_canister_fails_and_keeps_the_last_save() world.insert_resource(canister_ledger(&[(east, &[3, 3])])); refused(&mut world, "two saved canisters have the id 3"); } + +/// The catalog [`items`] plus one item, `lost_core`, that no base fixture +/// defines: a loaded catalog a saved item can fall outside of. +fn items_with_lost_core() -> GameItems { + let mut designs: Vec = items().iter().cloned().collect(); + designs.push(ItemDesign { + id: ItemDesignId::from("lost_core"), + name: "Lost core".to_string(), + about: "Lost core for tests.".to_string(), + category: ItemCategoryType::Parts, + mass_g: 5_000, + ask_cr: 60, + bid_cr: 45, + }); + GameItems::new(designs) +} + +/// The state of generation `generation`, like [`state`], except the +/// player's hold carries one of `item`: built against +/// [`items_with_lost_core`] so `freeze_ship` can build an inventory the base +/// catalog lacks. +fn state_with_player_item(generation: u64, item: &ItemDesignId) -> WorldSaveState { + let extended = items_with_lost_core(); + let mut world = World::new(); + let ship = world + .spawn(( + spaceship_scenario_object(SpaceshipConfig { + credits: 750, + ..default() + }), + ShipInventory::new(&extended, 10_000, [(item.clone(), 1)]), + DamageMarks::default(), + )) + .id(); + WorldSaveState { + player: SavedPlayer { + ship: freeze_ship(&world, ship).expect("a bare ship is settled"), + ..state(generation, 750).player + }, + ..state(generation, 750) + } +} + +/// A ledger cell at `coord` holding one ship `id` whose hold carries `item`, +/// built through the record's RON form like [`ledger`], from a real frozen +/// ship over [`items_with_lost_core`]. +fn ledger_with_item(id: &str, coord: SectorCoord, item: &ItemDesignId) -> FrozenSectors { + let extended = items_with_lost_core(); + let mut world = World::new(); + let ship = world + .spawn(( + spaceship_scenario_object(SpaceshipConfig::default()), + ShipInventory::new(&extended, 10_000, [(item.clone(), 1)]), + DamageMarks::default(), + )) + .id(); + let frozen = freeze_ship(&world, ship).expect("a bare ship is settled"); + let transform = ron::to_string(&Transform::IDENTITY).unwrap(); + ron::from_str(&format!( + "{{{}: Visited([(id: Some({id:?}), name: None, transform: {transform}, \ + visibility: None, motion: None, body: Ship({}))])}}", + ron::to_string(&coord).unwrap(), + ron::to_string(&frozen).unwrap() + )) + .unwrap() +} + +/// A ledger cell at `coord` holding one canister `id` whose contents carry +/// `item`, built through the record's RON form like [`canister_ledger`], +/// from a real frozen canister over [`items_with_lost_core`]. +fn canister_ledger_with_item(coord: SectorCoord, id: u64, item: &ItemDesignId) -> FrozenSectors { + let extended = items_with_lost_core(); + let mut world = World::new(); + let transform = ron::to_string(&Transform::IDENTITY).unwrap(); + let canister = world + .spawn(( + cargo_canister( + CargoCanister::new(&extended, item, 1), + Transform::IDENTITY, + Vec3::ZERO, + AssetRef::from("canister.glb#Scene0"), + ), + CargoCanisterRuntimeId(id), + )) + .id(); + let frozen = freeze_canister(&world, canister).expect("a whole canister"); + ron::from_str(&format!( + "{{{}: Visited([(id: None, name: None, transform: {transform}, \ + visibility: None, motion: None, body: Canister({}))])}}", + ron::to_string(&coord).unwrap(), + ron::to_string(&frozen).unwrap() + )) + .unwrap() +} + +/// A ledger cell at `coord` holding one ship `id` held back as a +/// [`SectorShip`], whose spawn stock carries `item`, built through the +/// record's RON form like [`ledger`]. +fn pending_ship_ledger(id: &str, coord: SectorCoord, item: &ItemDesignId) -> FrozenSectors { + let ship = SectorShip { + id: id.to_string(), + position: Meters3::ZERO, + rotation: Quat::IDENTITY, + initial_velocity: MetersPerSecond3::ZERO, + clearance: Meters(20.0), + design: ShipDesign::default(), + condition: SectorShipConditionType::Intact, + crew: Some(SectorShipCrew { + allegiance: Allegiance::Neutral, + patrol: Vec::new(), + stops: Vec::new(), + leash: Meters(5_000.0), + }), + civilization: CivilizationId { + world_seed: 0, + node: [0, 0, 0], + }, + role: ShipRoleType::Civilian, + stock: ShipInventoryStock::new([(item.clone(), 1)]), + credits: 120, + }; + let transform = ron::to_string(&Transform::IDENTITY).unwrap(); + ron::from_str(&format!( + "{{{}: Visited([(id: Some({id:?}), name: None, transform: {transform}, \ + visibility: None, motion: None, body: PendingShip({}))])}}", + ron::to_string(&coord).unwrap(), + ron::to_string(&ship).unwrap() + )) + .unwrap() +} + +/// A ledger cell at `coord` holding `body` under `id`, built through the +/// record's RON form like [`ledger`]. +fn ledger_holding(coord: SectorCoord, id: Option<&str>, body: &FrozenBodyType) -> FrozenSectors { + let transform = ron::to_string(&Transform::IDENTITY).unwrap(); + ron::from_str(&format!( + "{{{}: Visited([(id: {}, name: None, transform: {transform}, visibility: None, \ + motion: None, body: {})])}}", + ron::to_string(&coord).unwrap(), + ron::to_string(&id).unwrap(), + ron::to_string(body).unwrap() + )) + .unwrap() +} + +/// Every frozen body field that can hold an item, each holding `item` in +/// that one field only, frozen by the production `freeze_*` paths over +/// [`items_with_lost_core`]: `(saved id, body, the holder a refusal names)`. +/// The mined ore and the mined canister queue have no public constructor, so +/// they are read from RON, as a load reads them. +fn frozen_item_holders(item: &ItemDesignId) -> Vec<(Option<&'static str>, FrozenBodyType, String)> { + let extended = items_with_lost_core(); + let mut world = World::new(); + let canister = || CargoCanister::new(&extended, item, 1); + let reload = || { + SectionReload::from_config( + SectionReloadConfig { + delay: 1.0, + amount: 1, + }, + item.clone(), + ) + }; + let section = |world: &mut World, parent: Entity, held: &dyn Fn(&mut EntityWorldMut)| { + let mut section = world.spawn(( + SectionMarker, + EntityId("section".to_string()), + Health::new(100.0), + SectionAnimations::new(Vec::new()), + SectionBuildConfig(SectionConfig { + base: BaseSectionConfig { + id: "hull".to_string(), + ..default() + }, + kind: SectionKind::Hull(HullSectionConfig::default()), + }), + ChildOf(parent), + )); + held(&mut section); + }; + let ship = |world: &mut World, held: &dyn Fn(&mut EntityWorldMut)| { + let ship = world + .spawn(( + spaceship_scenario_object(SpaceshipConfig::default()), + ShipInventory::new(&extended, 10_000, []), + DamageMarks::default(), + )) + .id(); + section(world, ship, held); + FrozenBodyType::Ship(Box::new( + freeze_ship(world, ship).expect("a bare ship is settled"), + )) + }; + let rock = |world: &mut World, node: &dyn Fn(&mut EntityWorldMut)| { + let rock = world + .spawn(( + AsteroidMarker, + AsteroidSeed(1), + AsteroidRadius(1.0), + AsteroidKind(AsteroidKindId::from("rock")), + AsteroidTexture(AssetRef::from("asteroid.png".to_string())), + DestroySound(None), + LockSignature(1.0), + BodyRadius(1.0), + )) + .id(); + node(&mut world.spawn((DamageMarks::default(), ChildOf(rock)))); + FrozenBodyType::Asteroid(freeze_asteroid(world, rock).expect("a bare rock is settled")) + }; + let mined_queue = || -> MinedCanisterQueue { + ron::from_str(&format!( + "(waiting: [(canister: {}, at: (0.0, 0.0, 0.0), normal: (1.0, 0.0, 0.0))])", + ron::to_string(&canister()).unwrap() + )) + .unwrap() + }; + let east = SectorCoord::new(1, 0, 0); + + let reload_ship = ship(&mut world, &|section| { + section.insert(reload()); + }); + let suspended_ship = ship(&mut world, &|section| { + section.insert(SuspendedSectionAmmo { + capacity: 1, + reload: Some(reload()), + }); + }); + let ejecting_ship = ship(&mut world, &|section| { + section.insert(CargoIntakeEjectionQueue([canister()].into())); + }); + let fragment = world.spawn(ShipWreckFragmentMarker).id(); + section(&mut world, fragment, &|section| { + section.insert(reload()); + }); + let fragment = FrozenBodyType::WreckFragment( + freeze_wreck_fragment(&world, fragment).expect("a bare wreck is settled"), + ); + let mined_rock = rock(&mut world, &|node| { + node.insert( + ron::from_str::(&format!( + "(item: {}, corners: 1, at: (0.0, 0.0, 0.0), normal: (1.0, 0.0, 0.0))", + ron::to_string(item).unwrap() + )) + .unwrap(), + ); + }); + let queued_rock = rock(&mut world, &|node| { + node.insert(mined_queue()); + }); + let drop = world.spawn((MinedOreDrop, mined_queue())).id(); + let drop = FrozenBodyType::OreDrop(freeze_ore_drop(&world, drop)); + + let quoted = |id: &str| format!("'{id}'"); + vec![ + (Some("reload_ship"), reload_ship, quoted("reload_ship")), + ( + Some("suspended_ship"), + suspended_ship, + quoted("suspended_ship"), + ), + ( + Some("ejecting_ship"), + ejecting_ship, + quoted("ejecting_ship"), + ), + (None, fragment, format!("a body in sector {east}")), + (Some("mined_rock"), mined_rock, quoted("mined_rock")), + (Some("queued_rock"), queued_rock, quoted("queued_rock")), + (None, drop, format!("a body in sector {east}")), + ] +} + +/// A saved world that holds an item the loaded catalog lacks is refused by +/// the Load list's check and by open, with the item and its holder, in every +/// holder class; the check takes no lock. The same world opens once the +/// catalog holds the item. +#[test] +fn a_world_holding_an_item_the_catalog_lacks_is_refused_by_check_and_open() { + let root = tempfile::tempdir().unwrap(); + let (folder, lock) = create_world(root.path(), "Items").unwrap(); + let extended = items_with_lost_core(); + let item = ItemDesignId::from("lost_core"); + let east = SectorCoord::new(1, 0, 0); + let refused = |generation: u64, owner: &str| { + Err(WorldRefusal::Unreadable(format!( + "state.{generation}.ron: unknown item 'lost_core' held by {owner}" + ))) + }; + + // Each generation's state file replaces the last (write_world removes + // the previous one), so only the just-written case opens; check and open + // for it before moving to the next. + write_world( + &folder, + &header("Items", 1), + &state_with_player_item(1, &item), + ) + .unwrap(); + // check_world takes no lock: it still answers while `lock` is held. + assert_eq!( + check_world(root.path(), "items", &catalog(7), &items()), + refused(1, "the player ship 'player'") + ); + drop(lock); + assert_eq!( + open_world(root.path(), "items", &catalog(7), &items()).map(|_| ()), + refused(1, "the player ship 'player'") + ); + + let mut cases: Vec<(FrozenSectors, String)> = vec![ + ( + ledger_with_item("held_ship", east, &item), + "'held_ship'".to_string(), + ), + ( + canister_ledger_with_item(east, 5, &item), + "the canister 5".to_string(), + ), + ( + pending_ship_ledger("pending_ship", east, &item), + "'pending_ship'".to_string(), + ), + ]; + cases.extend( + frozen_item_holders(&item) + .into_iter() + .map(|(id, body, owner)| (ledger_holding(east, id, &body), owner)), + ); + let last = 1 + cases.len() as u64; + for (generation, (sectors, owner)) in (2..).zip(cases) { + let saved_state = WorldSaveState { + sectors, + ..state(generation, 750) + }; + write_world(&folder, &header("Items", generation), &saved_state).unwrap(); + assert_eq!( + check_world(root.path(), "items", &catalog(7), &items()), + refused(generation, &owner), + "check_world at generation {generation}" + ); + assert_eq!( + open_world(root.path(), "items", &catalog(7), &items()).map(|_| ()), + refused(generation, &owner), + "open_world at generation {generation}" + ); + } + + let (_, _lock, _, saved) = open_world(root.path(), "items", &catalog(7), &extended).unwrap(); + assert_eq!( + saved.generation, last, + "the extended catalog opens the last case" + ); +} + +/// A save whose state holds an item the armed catalog lacks fails visibly +/// and writes nothing, so the last good save stays. +#[test] +fn a_save_holding_an_item_the_catalog_lacks_fails_and_keeps_the_last_save() { + let root = tempfile::tempdir().unwrap(); + let (mut world, player) = armed_session(root.path()); + run_until_idle(&mut world); + assert_eq!( + world.resource::().status(), + &WorldSaveStatus::Saved { generation: 1 } + ); + let folder = root.path().join("session"); + let saved = std::fs::read(folder.join("state.1.ron")).unwrap(); + + let extended = items_with_lost_core(); + let item = ItemDesignId::from("lost_core"); + world + .entity_mut(player) + .insert(ShipInventory::new(&extended, 10_000, [(item, 1)])); + world.resource_mut::().request_leave(); + run_until_idle(&mut world); + let WorldSaveStatus::Failed(reason) = world.resource::().status() else { + panic!( + "an item the catalog lacks fails the save, not {:?}", + world.resource::().status() + ); + }; + assert!(reason.contains("unknown item 'lost_core'"), "{reason}"); + assert_eq!(files(&folder), ["state.1.ron", "world.lock", "world.ron"]); + assert_eq!(std::fs::read(folder.join("state.1.ron")).unwrap(), saved); +} diff --git a/crates/nova_world_base/src/sector_ships.rs b/crates/nova_world_base/src/sector_ships.rs index 0881315e6..bbbbbb293 100644 --- a/crates/nova_world_base/src/sector_ships.rs +++ b/crates/nova_world_base/src/sector_ships.rs @@ -37,7 +37,11 @@ use bevy::prelude::{Quat, Vec3}; use nova_events::prelude::{Meters, Meters3}; -use nova_gameplay::prelude::{Fnv32, ItemType, SeedStream, ShipInventoryStock}; +use nova_gameplay::prelude::{ + Fnv32, GameItems, ItemDesignId, SeedStream, ShipInventoryStock, ITEM_CARBON_ORE, + ITEM_HULL_PLATE, ITEM_IRON_ORE, ITEM_PDC_ROUND, ITEM_RAIL_SLUG, ITEM_RATIONS, + ITEM_SALVAGED_PARTS, ITEM_STONE_ORE, ITEM_TORPEDO, ITEM_WATER_ICE, +}; use nova_scenario::prelude::{ShipDesign, HULL_SECTION_CARGO_G}; use nova_ship::prelude::SectionKind; use nova_world::prelude::*; @@ -157,6 +161,7 @@ pub struct PlannedShip { /// `stock` when its hold cannot fit one unit of its role's lightest item. pub fn plan_ship( parts: &ShipPartSnapshot, + items: &GameItems, civilizations: &CivilizationField, seed: u32, hull: HullSlot<'_>, @@ -232,6 +237,7 @@ pub fn plan_ship( }; let stock = ship_stock( parts, + items, &layout.design, role, condition, @@ -363,9 +369,11 @@ pub(crate) fn plan_patrol( /// # Panics /// /// When a section of `design` names no part of `parts`: every generated -/// design is built from the snapshot it is checked against. +/// design is built from the snapshot it is checked against. When `items` +/// lacks an item of the role's mix. pub fn ship_stock( parts: &ShipPartSnapshot, + items: &GameItems, design: &ShipDesign, role: ShipRoleType, condition: SectorShipConditionType, @@ -389,44 +397,44 @@ pub fn ship_stock( .count() as u64; let hold = hulls * u64::from(HULL_SECTION_CARGO_G); // Ammunition of every kind on an armed ship, whichever its weapons fire. - let mix: &[(ItemType, f32)] = match role { + let mix: &[(&str, f32)] = match role { ShipRoleType::Industrial => &[ - (ItemType::StoneOre, 2.0), - (ItemType::IronOre, 2.0), - (ItemType::WaterIce, 2.0), - (ItemType::CarbonOre, 2.0), - (ItemType::SalvagedParts, 3.0), - (ItemType::Rations, 1.0), + (ITEM_STONE_ORE, 2.0), + (ITEM_IRON_ORE, 2.0), + (ITEM_WATER_ICE, 2.0), + (ITEM_CARBON_ORE, 2.0), + (ITEM_SALVAGED_PARTS, 3.0), + (ITEM_RATIONS, 1.0), ], ShipRoleType::Civilian => &[ - (ItemType::Rations, 5.0), - (ItemType::SalvagedParts, 3.0), - (ItemType::WaterIce, 1.0), - (ItemType::HullPlate, 1.0), + (ITEM_RATIONS, 5.0), + (ITEM_SALVAGED_PARTS, 3.0), + (ITEM_WATER_ICE, 1.0), + (ITEM_HULL_PLATE, 1.0), ], ShipRoleType::Scavenger => &[ - (ItemType::PdcRound, 2.0), - (ItemType::RailSlug, 1.0), - (ItemType::Torpedo, 1.0), - (ItemType::HullPlate, 3.0), - (ItemType::SalvagedParts, 3.0), - (ItemType::Rations, 1.0), + (ITEM_PDC_ROUND, 2.0), + (ITEM_RAIL_SLUG, 1.0), + (ITEM_TORPEDO, 1.0), + (ITEM_HULL_PLATE, 3.0), + (ITEM_SALVAGED_PARTS, 3.0), + (ITEM_RATIONS, 1.0), ], ShipRoleType::Armored => &[ - (ItemType::PdcRound, 3.0), - (ItemType::RailSlug, 2.0), - (ItemType::Torpedo, 1.0), - (ItemType::HullPlate, 3.0), - (ItemType::SalvagedParts, 2.0), - (ItemType::Rations, 1.0), + (ITEM_PDC_ROUND, 3.0), + (ITEM_RAIL_SLUG, 2.0), + (ITEM_TORPEDO, 1.0), + (ITEM_HULL_PLATE, 3.0), + (ITEM_SALVAGED_PARTS, 2.0), + (ITEM_RATIONS, 1.0), ], }; let lightest = mix .iter() .map(|(item, _)| *item) - .min_by_key(|item| item.mass_g()) + .min_by_key(|item| items.design(&ItemDesignId::from(*item)).mass_g) .expect("every role mixes at least one item"); - if hold < u64::from(lightest.mass_g()) { + if hold < u64::from(items.design(&ItemDesignId::from(lightest)).mass_g) { return None; } @@ -450,17 +458,18 @@ pub fn ship_stock( drawn.push((item, 0.5 + stream.unit())); } let total: f32 = drawn.iter().map(|(_, weight)| weight).sum(); - let mut stacks: Vec<(ItemType, u32)> = drawn + let mut stacks: Vec<(ItemDesignId, u32)> = drawn .into_iter() .filter_map(|(item, weight)| { + let id = ItemDesignId::from(item); let portion = (target as f64 * f64::from(weight / total)) as u64; - let units = portion / u64::from(item.mass_g()); + let units = portion / u64::from(items.design(&id).mass_g); let units = u32::try_from(units).expect("a generated hold's units fit a u32"); - (units > 0).then_some((item, units)) + (units > 0).then_some((id, units)) }) .collect(); if stacks.is_empty() { - stacks.push((lightest, 1)); + stacks.push((ItemDesignId::from(lightest), 1)); } Some(ShipInventoryStock::new(stacks)) } diff --git a/crates/nova_world_base/src/test_support.rs b/crates/nova_world_base/src/test_support.rs index 9b09e46b9..73499c025 100644 --- a/crates/nova_world_base/src/test_support.rs +++ b/crates/nova_world_base/src/test_support.rs @@ -76,9 +76,11 @@ pub fn arm_save_fixture(world: &mut World) -> Entity { }) .collect(), ); - let generator = NovaLayeredWorld::from_loaded(&loaded, &styles) + let items = nova_gameplay::test_support::test_items(); + let generator = NovaLayeredWorld::from_loaded(&loaded, &styles, &items) .expect("the fixture packs build a snapshot and every role has a style"); world.insert_resource(loaded); + world.insert_resource(items); world.insert_resource(WorldConfig { seed: 42, sector_edge: OPEN_WORLD_SECTOR_EDGE, diff --git a/crates/nova_world_base/src/tests.rs b/crates/nova_world_base/src/tests.rs index 9ac0baedb..2f39d299f 100644 --- a/crates/nova_world_base/src/tests.rs +++ b/crates/nova_world_base/src/tests.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeMap, panic::AssertUnwindSafe}; use bevy::ecs::system::RunSystemOnce; use nova_assets::prelude::{ContentCatalogDigest, LoadedSectionPack}; use nova_events::prelude::Meters3; -use nova_gameplay::prelude::{Fnv64, ItemType}; +use nova_gameplay::prelude::{Fnv64, ItemDesignId, ITEM_HULL_PLATE}; use nova_scenario::prelude::{ resolve_ship_design, GameShipDesigns, ScenarioConfig, SectionSource, ShipDesign, ShipDesignSource, SpaceshipSectionConfig, @@ -50,8 +50,12 @@ fn styles() -> GameStyles { /// A generator pinned to [`loaded`] and checked against [`styles`]. pub(crate) fn fixture_world() -> NovaLayeredWorld { - NovaLayeredWorld::from_loaded(&loaded(), &styles()) - .expect("the fixture packs build a snapshot and every role has a style") + NovaLayeredWorld::from_loaded( + &loaded(), + &styles(), + &nova_gameplay::test_support::test_items(), + ) + .expect("the fixture packs build a snapshot and every role has a style") } fn scenario(role: ScenarioRole) -> CurrentScenario { @@ -68,6 +72,7 @@ fn world(role: ScenarioRole) -> World { world.insert_resource(OpenWorldSession { seed: SEED }); world.insert_resource(loaded()); world.insert_resource(styles()); + world.insert_resource(nova_gameplay::test_support::test_items()); world } @@ -134,6 +139,7 @@ fn the_open_world_describes_the_same_sectors_in_any_visit_order() { fn every_planned_ship_carries_goods_its_hold_fits_and_credits() { let config = session_config(); let parts = config.generator.parts(); + let items = config.generator.items(); let sections = GameSections( parts .parts() @@ -146,7 +152,7 @@ fn every_planned_ship_carries_goods_its_hold_fits_and_credits() { let description = generate_sector(&config, coord) .unwrap_or_else(|fault| panic!("sector {coord:?}: {fault}")); for ship in description.ships() { - let stacks: Vec<(ItemType, u32)> = ship.stock.stacks().collect(); + let stacks: Vec<(&ItemDesignId, u32)> = ship.stock.stacks().collect(); assert!(!stacks.is_empty(), "ship {} carries no goods", ship.id); let (resolved, errors) = resolve_ship_design( &ShipDesignSource::Inline(ship.design.clone()), @@ -155,7 +161,7 @@ fn every_planned_ship_carries_goods_its_hold_fits_and_credits() { ); assert!(errors.is_empty(), "ship {}: {errors:?}", ship.id); assert!( - ship.stock.mass_g() <= u64::from(resolved.cargo_capacity_g()), + ship.stock.mass_g(items) <= u64::from(resolved.cargo_capacity_g()), "ship {} overfills its hold", ship.id ); @@ -186,8 +192,11 @@ fn every_planned_ship_carries_goods_its_hold_fits_and_credits() { } SectorShipConditionType::Derelict => { wrecks += 1; - plateless_wrecks += - usize::from(!stacks.iter().any(|(item, _)| *item == ItemType::HullPlate)); + plateless_wrecks += usize::from( + !stacks + .iter() + .any(|(item, _)| item.as_str() == ITEM_HULL_PLATE), + ); 1..=500 } }; @@ -231,6 +240,7 @@ fn every_planned_ship_carries_goods_its_hold_fits_and_credits() { assert_eq!( ship_stock( parts, + items, &docks_only, role, SectorShipConditionType::Derelict, @@ -298,7 +308,7 @@ fn a_pinned_window_generates_the_recorded_bodies() { .collect(); assert_eq!( Fnv64::new().write(canonical.as_bytes()).finish(), - 0x172e_361a_95a1_a213, + 0x81e9_2080_3e38_ff6b, "the pinned window's bodies changed" ); } diff --git a/docs/architecture.md b/docs/architecture.md index e3bf61bbe..f87e027d7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,7 @@ real code lives under `crates/`. | `nova_world_base` | The base game's open world over `nova_world`: the complete concrete `NovaLayeredWorld` generator, the `OpenWorldSession` seed New Game writes, and the stable ids the world and its `open_world` bootstrap share. The generator reads three independent global environment fields (material density, volatiles, human activity) and decides, at each node of a 50 km lattice jittered by up to 10 km, one asteroid-rich, rock-only, planet-heavy or derelict-field cluster or none, each reaching at most 8 km from its anchor. A planet-heavy cluster stands two to four worlds at the corners of a pair, a triangle or a tetrahedron, their bodies kept `CLEARANCE_MARGIN` apart while their wells may overlap; a derelict field holds rocks around its hulls. A cell replays every node whose cluster can reach it and owns each body whose centre it holds, so a cluster crosses faces and every cell agrees about it. A cell resolves every cluster's planetoids, then its rocks, then its hulls. A body whose clearance sphere crosses its cell's face, or that comes within the 500 m `CLEARANCE_MARGIN` of a body placed before it in that cell, is skipped and counted; a planetoid that cannot be placed refuses the cell with `SectorFault::Generation`. A hull is placed only in a cell where its own cluster placed a rock or planetoid; otherwise the cell tries the escort rocks the cluster drew on the anchor side of that hull, places the first that fits and counts each one it tried, or skips the hull as having no companion. Escorts count toward each type's derived extent, the halo and the gap between clusters, and a cell never looks past its faces for one. Overlap retries translate a placed escort and its hull together, keeping them 1,650-2,200 m apart; an impossible retry faults the sector. Every planetoid authors the mass that gives it a sphere of influence of 3.5 outer body radii under the default `GravitySettings`, and remains a static well. A cell that owns no cluster body may draw a background scatter of one to five rocks. The generator refuses an edge wider than 128 km or too narrow for its widest core of planetoids or a scatter, a world spacing or escort distance too short to clear the bodies it separates, bands that derive a cluster past its extent limit, and a planetoid reach whose surface pull passes the default surface gravity cap. `sector_clusters` and `EnvironmentFields` are public so debug examples read the same plan and fields; no streamed entity carries them. `CivilizationField` is the provisional civilization field that ship selection reads; the `world_civilizations` diagnostic reads it too. One civilization stands at each node of a 240 km lattice jittered by up to 40 km per axis. Its identity is the world seed plus the signed node, never its generated name. Its centroid, living or extinct status, advancement, role preference and allegiance (Enemy, Player or Neutral, one chance in three each, independent of the others) each come from their own seeded stream, so they do not depend on visit order. Influence is 1 at the centroid, 0.5 at 160 km and reaches zero only at 320 km, with no border between civilizations. The lattice bound keeps every place within reach of a centroid, and a place with none refuses with `SectorFault::Generation`. A place farther from the origin than a centroid gets up to four times its selection weight. Advancement rises with the centroid's distance from the origin to saturation at 1,000 km, plus a seeded offset of up to 0.1. `role_weights` gives the four roles' weights before content eligibility. `ShipPartSnapshot` is the provisional part catalog generated ships are built from. The open world builds one from `LoadedSectionPacks` when it arms and pins it in `NovaLayeredWorld` with the catalog digest, and every generated ship is laid out from it. `plan_ship` turns each hull a cell owns into a ship before the cell's spatial checks: a primary civilization per cluster from those in reach of its anchor by selection weight, a 10% chance for each later hull to belong to another civilization in reach by influence, a role from the civilization's role weights at the cluster's environment among the roles its advancement can build (read through the provisional `SHIP_ADVANCEMENT_CURVE`, `Linear`), then `generate_ship` for a living civilization or `generate_wreck` for an extinct one. The planned ship carries the civilization's id and the role to its manifest entry. An intact ship also gets a crew carrying its civilization's allegiance; after the cell resolves bodies and relocates overlaps, `plan_patrols` gives each crew a loop of up to four seeded waypoints 1,500 m around the ship in its level plane, each kept only where the ship's whole clearance fits the cell and clears every other placed body by `CLEARANCE_MARGIN` - fewer than two that fit leaves the ship holding its spawn point instead, counted in `SectorClusters::routeless_ships`. A derelict gets no crew. Every ship's hold and credit balance come from `ship_stock` and `ship_credits`, each from its own stream keyed like the ship and scaled by the civilization's advancement: a role-specific item mix toward a seeded share of what its hull sections hold at `HULL_SECTION_CARGO_G` each, a wreck's toward roughly half that share of its remaining hull drawn apart from the intact ship's, and a hold that cannot fit one unit of the role's lightest item fails the cell; a wreck's credits keep a seeded share of an intact balance at the same advancement, at least one credit. A retired cell comes back with its ships' stock and credits as the session left them, and a saved Open World keeps it across a Load too, on the desktop build; the web build keeps no saved worlds. The generator gives mobile bodies a seeded tangent only for a unique well whose full orbit fits the clear, unfaded band in its owning sector; otherwise it gives an unsafe single-well start an outward escape, free space an explicit zero coast, or fails when an overlap has no safe relocation. An intact ship stands level on its nearest planetoid's outward direction, or on +Y with none, turned by the cluster's seeded yaw; a wreck takes a seeded uniform 3D orientation. Overlap relocation preserves a ship's rotation, even if its nearest well changes. Every draw is keyed by the world seed, the cluster node and the hull's slot, so a ship is the same from every cell. A planned hull reserves `SECTOR_SHIP_CLEARANCE_MAX` in the cluster's extent and halo; the cell's checks use the ship's own root-centred clearance. A layout or content fault fails the cell: a sector fault panics streaming, so a ship that no layout fits crashes the world where it stands. The static content lint and the `world_ships` debug example build their own from the same repo bundle packs. It takes the base and mod section packs in any order, sorts packs and ids, and allows a section overlay only along a dependency chain, where the most dependent pack wins. It refuses non-finite or non-positive part stats, a docking capture angle outside 0 to 90 degrees, a socketed weapon or thruster that fires between two axes, a socketed weapon, thruster, cargo intake or docking port with a socket on the face it fires, opens or docks through, and a missing hull, controller, thruster, cargo intake or docking port, and reports every fault. It excludes parts without link points, uncharged railguns and parts that fail `nova_ship::cell_grid_fit` for any other reason, then scores each family (hull health per volume, thrust, torque, weapon damage per second, intake aperture, docking capture distance in engine units, mining carve volume per second) and normalizes it log-linearly: the weakest usable part needs advancement 0, the strongest 1. `eligible` groups a role's parts by source so each pack weighs the same; civilian and industrial ships get no weapon, and only an industrial ship gets a mining beam. `content_hash` is a versioned FNV-1a 64 of the sorted effective catalog. `generate_ship` is the provisional shape-first ship layout. `world_ships` poses every role at its lowest eligible and full advancement with each drive layout beside four standard-plan WFC hulls, flies each intact ship, stands one `generate_wreck` derelict per role with every active section disabled in place, no controller and Neutral allegiance, checks that commands, a canister and a hit leave it inert but damageable, and reports the snapshot hash, each ship's mass, thrust per cell and hull and weapon parts, and the missing weathered section art. It strips the generated skin so hulls compare bare; `--skin` or `C` restores it, and the harness also shoots the low and high rows clad. Clad WFC hulls name no style and wear the undressed derivation. It builds one `ShipDesign` from a snapshot, a role, an advancement and a hull seed: a mirror-symmetric spine of structural cubes from a one-cell nose to a stern that holds a bank of one drive (a centre column with mirrored pairs, or pairs only), a cross-section that changes along the length, flight computers in centreline cells inside the spine, each mounted on the hull surface instead where no flight computer part fits its cell, the role's weapons or cargo intake, and docking ports: a mirrored pair out of the hull's sides where one fits, else one port alone. It wears the derived skin in its role's base style (`role_style_id`): civilian `civilian`, industrial `industrial`, scavenger `salvage`, armored `armoured`. Those ids are `nova_world_base` constants the style builders share. The spine mixes up to three structural cube types in rings, decks or a shell. It prefers one source per hull, except for scavengers. Civilian and industrial ships carry no weapon; scavengers get 1 to 3 weapon slots and armored ships 1 to 5, rising with advancement. An eligible industrial request draws 0, 1 or 2 mining beams instead, the chance and the pair threshold both rising with advancement; a drawn count with no legal mount fails the request rather than flying with fewer. Drives and weapons draw parts that score within 0.5 below the ship's advancement before weaker parts of the same source, and each weapon slot then prefers a part not yet mounted. An industrial intake and every docking port open to starboard or port with structural cubes across their backs. An industrial spine holds one flat body run, at least one cell wide, long and tall enough for its first ranked intake's mirrored pair beside its first ranked docking port; a drawn body with no such run has its first body stations flattened, and the spine never shrinks below the length that holds the run. A layout whose drives give less than 0.06 thrust per cell of collider mass at density 1, or that has fewer than one controller per 50 cells, is rebuilt with a larger bank and more controllers. Section ids name planned slots. Every layout is checked for unique ids, eligible snapshot parts, the role's families, mated contacts, symmetry but for a lone docking port, backed side intakes and docking ports, one socket graph (`derive_link_point_graph`), clear exit and docking lanes (`blocked_exits`), the thrust and controller floors, a clearance ceiling from 80 m at advancement 0 to 400 m at 1, and one off-centre outer structural cube outside every docking port's back that a wreck could omit alone (`Unruinable`), so every generated ship can be ruined. After 8 failed seeded attempts it returns a `ShipLayoutFailure` with the seed, civilization, role and last constraint. `generate_wreck` ruins the same request's intact design: it keeps the role, style, source, advancement, fittings and cell bounds, and omits seeded breaches of structural cubes, each grown inward from the outer hull, that keep one socket graph, clear lanes, the ceiling and the cubes across every docking port's back. Each of 8 omission plans digs toward a fifth of the cubes (at least 3) off the mirror image, and the first to get there wins. Otherwise the plan with the most wins if it reaches the hull's floor: as many mirror pairs of off-centre outer cubes as can each go alone, capped at that target and at what one deterministic pass in cell order omits together. Every generated hull has at least one. When all 8 plans fall short, that pass is the wreck; a pass that fails a wreck check fails the request, and nothing rerolls or skips it. No weathered or damaged part exists, so holes are its only ruin cue. `NovaWorldBasePlugin`, added by `AppBuilder` on the default game path after the scenario plugin, installs `NovaWorldPlugin` and one exclusive `Update` system ordered before `NovaWorldSystems::Cleanup`: it arms `WorldConfig` and puts `WorldObserver` on the player ship only while an `OpenWorld` scenario is live with exactly one player ship, and removes the config everywhere else. An armed world keeps its generator; two configs compare by catalog digest and snapshot hash only. A missing session, a second player ship, no `LoadedSectionPacks` or `GameStyles`, ship parts that do not build a snapshot, a role style that no loaded style authors (`ShipPartFault::MissingRoleStyle`), or a catalog digest that changes under an armed world panics, the last before any config is written. The same build and platform give the same pristine sectors from one seed in any visit order; what a player changes lasts the session, so a retired sector comes back as it was left. | | `nova_events` | Game event kinds and entity identity components, shared between gameplay and scenario. Also the world's scale: the `Meters`/`MetersPerSecond`/`MetersPerSecondSquared`/`Meters3`/`MetersPerSecond3` quantity types and `METERS_PER_UNIT` (`units`), and `LOAD_LIMIT` (`scale`). See [Units and scale](#units-and-scale). | | `nova_events_macros` | Procedural macros behind `nova_events`' derives. | -| `nova_assets` | `bevy_asset_loader` setup. Loads glb/textures/shaders/sounds, and loads the base game's own generated content (`assets/base/`) through the same bundle machinery as mods. Owns the mod merge (`register_bundles`, `EnabledMods`, `ModCatalog`) and `LoadedSectionPacks` (merged sections and versioned `ContentCatalogDigest` of effective content and mod metadata, independent of map/merge order; rejects non-finite values and excludes raw resource bytes), safe mode (`safe_mode.rs`: the optional half of the installed set loads outside the boot gate, and a broken mod is disabled, persisted off and reported; the merge runs `lint_section_config` on every section of every enabled bundle before it publishes, so an error in the base bundle inserts `FatalAssetFailure` and ends in `GameAssetsStates::Failed`, and an error in a mod quarantines that mod and every enabled mod that depends on it, except in `GameStates::Playing`, where an invalid mod, a failed mod load, or a dependent of a disabled mod fails the run the same way), the portal client and downloads (`portal/`), and prefs persistence. | +| `nova_assets` | `bevy_asset_loader` setup. Loads glb/textures/shaders/sounds, and loads the base game's own generated content (`assets/base/`) through the same bundle machinery as mods. Owns the mod merge (`register_bundles`, `EnabledMods`, `ModCatalog`; `items.rs` refuses item faults per pack and the merge inserts `nova_gameplay`'s `GameItems`) and `LoadedSectionPacks` (merged sections and versioned `ContentCatalogDigest` of effective content and mod metadata, independent of map/merge order; rejects non-finite values and excludes raw resource bytes), safe mode (`safe_mode.rs`: the optional half of the installed set loads outside the boot gate, and a broken mod is disabled, persisted off and reported; the merge runs `lint_section_config` on every section of every enabled bundle before it publishes, so an error in the base bundle inserts `FatalAssetFailure` and ends in `GameAssetsStates::Failed`, and an error in a mod quarantines that mod and every enabled mod that depends on it, except in `GameStates::Playing`, where an invalid mod, a failed mod load, or a dependent of a disabled mod fails the run the same way), the portal client and downloads (`portal/`), and prefs persistence. | | `nova_modding` | Bundle/content/catalog ASSET LOADERS and the `Content` routing enum. See [Mod files](https://alexjercan.github.io/nova-protocol/create/mod-files/). | | `nova_mod_format` | Pure serde types for the mod formats (bundle manifests, catalog declarations, the portal wire schema). Engine-free; re-exported by `nova_modding`. The static mod portal is built by `scripts/gen-portal.py`, not a crate. See [Publish a mod](https://alexjercan.github.io/nova-protocol/create/publish-a-mod/). | diff --git a/docs/sections.md b/docs/sections.md index 70b450d33..74d935614 100644 --- a/docs/sections.md +++ b/docs/sections.md @@ -1267,7 +1267,7 @@ per contact. A symmetric rule - ram damage - wants both. - `SectionReload` (`sections/ammo.rs`): optional idle batch reload on a magazine, from the turret/torpedo/railgun config's `reload: ReloadConfig::Batch(SectionReloadConfig { delay, amount })` - (`Disabled` is the default). Seeded with the weapon's ammunition `ItemType` + (`Disabled` is the default). Seeded with the weapon's ammunition item id (`PdcRound` for a turret regardless of mount damage type, `RailSlug` for a railgun, `Torpedo` for a torpedo bay regardless of bay type). Every successful shot resets progress; every completed quiet delay moves diff --git a/examples/playable/block_bench.rs b/examples/playable/block_bench.rs index 3b1c6854f..90249d267 100644 --- a/examples/playable/block_bench.rs +++ b/examples/playable/block_bench.rs @@ -401,6 +401,7 @@ fn stand_position(index: usize, subjects: usize) -> Vec3 { fn bench_row( game_assets: &GameAssets, sections: &GameSections, + items: &GameItems, roster: Roster, style: StyleId, ) -> ScenarioConfig { @@ -438,7 +439,7 @@ fn bench_row( game_assets.cubemap.clone().into(), ) }; - refuse_broken(&scenario, sections); + refuse_broken(&scenario, sections, items); scenario } @@ -488,13 +489,14 @@ fn refuse_blocked(subject: &Subject, sections: &GameSections) { /// Run the roster through the game's OWN content gate before posing it, the /// `wfc_ships` claim held to by the hand-placed cases too: what stands on the /// bench is a ship the game would accept. -fn refuse_broken(scenario: &ScenarioConfig, sections: &GameSections) { +fn refuse_broken(scenario: &ScenarioConfig, sections: &GameSections, items: &GameItems) { let known = KnownSections::from_configs(sections.iter()); let issues = lint_scenario( scenario, &known, &KnownShipDesigns::default(), &HashSet::from([scenario.id.clone()]), + items, ); let errors: Vec<&str> = issues .iter() @@ -518,6 +520,7 @@ fn load_roster( game_assets: Res, sections: Res, styles: Res, + items: Res, requested: Res, mut roster: ResMut, ) { @@ -533,6 +536,7 @@ fn load_roster( commands.trigger(LoadScenario(bench_row( &game_assets, §ions, + &items, *roster, style, ))); @@ -549,6 +553,7 @@ fn restyle_on_key( game_assets: Res, sections: Res, styles: Res, + items: Res, mut roster: ResMut, ) { if keyboard.just_pressed(KeyCode::KeyL) { @@ -565,6 +570,7 @@ fn restyle_on_key( commands.trigger(LoadScenario(bench_row( &game_assets, §ions, + &items, *roster, style, ))); diff --git a/examples/playable/first_shift_map.rs b/examples/playable/first_shift_map.rs index d23573cf7..784c17213 100644 --- a/examples/playable/first_shift_map.rs +++ b/examples/playable/first_shift_map.rs @@ -155,10 +155,11 @@ fn load_map( game_assets: Res, sections: Res, ships: Res, + items: Res, pilot: Res, ) { let scenario = map_scenario(&game_assets, *pilot); - refuse_broken(&scenario, §ions, &ships); + refuse_broken(&scenario, §ions, &ships, &items); commands.trigger(LoadScenario(scenario)); } @@ -436,13 +437,19 @@ fn maintenance_point(index: usize, position: Meters3) -> ScenarioObjectConfig { } } -fn refuse_broken(scenario: &ScenarioConfig, sections: &GameSections, ships: &GameShipDesigns) { +fn refuse_broken( + scenario: &ScenarioConfig, + sections: &GameSections, + ships: &GameShipDesigns, + items: &GameItems, +) { let known = KnownSections::from_configs(sections.iter()); let issues = lint_scenario( scenario, &known, &KnownShipDesigns::from_configs(ships.iter()), &HashSet::from([scenario.id.clone()]), + items, ); let errors: Vec<_> = issues .iter() diff --git a/examples/playable/first_shift_ships.rs b/examples/playable/first_shift_ships.rs index c4f47110d..9f0fc3456 100644 --- a/examples/playable/first_shift_ships.rs +++ b/examples/playable/first_shift_ships.rs @@ -71,9 +71,10 @@ fn load_showcase( game_assets: Res, sections: Res, ships: Res, + items: Res, ) { let scenario = showcase(&game_assets); - refuse_broken(&scenario, §ions, &ships); + refuse_broken(&scenario, §ions, &ships, &items); commands.trigger(LoadScenario(scenario)); } @@ -175,13 +176,19 @@ fn ship_object( } } -fn refuse_broken(scenario: &ScenarioConfig, sections: &GameSections, ships: &GameShipDesigns) { +fn refuse_broken( + scenario: &ScenarioConfig, + sections: &GameSections, + ships: &GameShipDesigns, + items: &GameItems, +) { let known = KnownSections::from_configs(sections.iter()); let issues = lint_scenario( scenario, &known, &KnownShipDesigns::from_configs(ships.iter()), &HashSet::from([scenario.id.clone()]), + items, ); let errors: Vec<_> = issues .iter() diff --git a/examples/playable/mine_and_sell.rs b/examples/playable/mine_and_sell.rs index 9538f4ccf..85353227d 100644 --- a/examples/playable/mine_and_sell.rs +++ b/examples/playable/mine_and_sell.rs @@ -99,7 +99,7 @@ const TRADER_POSITION: Vec3 = Vec3::new(BERTH.x + 5.5, BERTH.y, BERTH.z); const PLAYER_CREDITS: u32 = 50; /// What the trader carries at spawn: no ore, so every ore it ends with is /// the ore it bought. -const TRADER_STOCK: &[(ItemType, u32)] = &[(ItemType::Rations, 10)]; +const TRADER_STOCK: &[(&str, u32)] = &[("Rations", 10)]; /// The trader's credits at spawn: enough for any one pulse's ore. const TRADER_CREDITS: u32 = 1_000; @@ -176,7 +176,9 @@ fn load_scene(mut commands: Commands, game_assets: Res, ships: Res().clone(); let seen: Vec<(Entity, u32, u32)> = world .query::<(Entity, &CargoCanister)>() .iter(world) - .map(|(entity, canister)| (entity, ore_in(canister), canister.total_mass_g())) + .map(|(entity, canister)| (entity, ore_in(canister), canister.total_mass_g(&items))) .collect(); for (entity, ore, mass) in seen { assert_eq!( mass, - ore * ORE.mass_g(), + ore * items.design(&ORE.into()).mass_g, "mine_and_sell: a canister's mass is not its ore's" ); let mut proof = world.resource_mut::(); @@ -472,7 +475,7 @@ mod walk { fn ore_in(canister: &CargoCanister) -> u32 { canister .stacks() - .filter(|(item, _)| *item == ORE) + .filter(|(item, _)| item.as_str() == ORE) .map(|(_, count)| count) .sum() } @@ -577,7 +580,9 @@ mod walk { let taken = proof.taken; let spawned: u32 = live.values().sum(); let picked: u32 = gone.iter().sum(); - let held = inventory(world, PLAYER_ID).count(ORE) + inventory(world, TRADER_ID).count(ORE); + let ore_id = ORE.into(); + let held = + inventory(world, PLAYER_ID).count(&ore_id) + inventory(world, TRADER_ID).count(&ore_id); info!( "mine_and_sell {stage}: {flipped} ore mined = {spawned} in {} live canister(s) \ + {queued} queued + {picked} picked up in {} canister(s); holds carry {held}", @@ -620,11 +625,17 @@ mod walk { picked, target_ore, "the intake took the tracked canister alone" ); + let items = world.resource::().clone(); + let ore_id = ORE.into(); let hold = inventory(world, PLAYER_ID); - assert_eq!(hold.count(ORE), picked, "the hold holds the picked-up ore"); assert_eq!( - hold.used_g(), - picked * ORE.mass_g(), + hold.count(&ore_id), + picked, + "the hold holds the picked-up ore" + ); + assert_eq!( + hold.used_g(&items), + picked * items.design(&ore_id).mass_g, "the hold's mass is the picked-up ore's" ); assert_eq!(credits(world, PLAYER_ID), PLAYER_CREDITS); @@ -639,14 +650,19 @@ mod walk { .collected .expect("the take was checked"); assert_eq!(check_ledger(world, "after the Sell"), collected); - let price = collected * ORE.bid_cr(); + let items = world.resource::().clone(); + let ore_id = ORE.into(); + let price = collected * items.design(&ore_id).bid_cr; let own = inventory(world, PLAYER_ID); let partner = inventory(world, TRADER_ID); assert!(own.is_empty(), "the warship's hold is empty after the Sell"); - let mut expected: Vec<(ItemType, u32)> = TRADER_STOCK.to_vec(); + let mut expected: Vec<(&str, u32)> = TRADER_STOCK.to_vec(); expected.push((ORE, collected)); expected.sort_by_key(|(item, _)| *item); - let mut stacks: Vec<(ItemType, u32)> = partner.stacks().collect(); + let mut stacks: Vec<(&str, u32)> = partner + .stacks() + .map(|(id, count)| (id.as_str(), count)) + .collect(); stacks.sort_by_key(|(item, _)| *item); assert_eq!(stacks, expected, "the trader holds its stock and the ore"); let own_credits = credits(world, PLAYER_ID); @@ -662,10 +678,12 @@ mod walk { /// The note line the Sell shows. fn sell_note(world: &World) -> Option { let collected = world.resource::().collected?; + let items = world.resource::(); + let design = items.design(&ORE.into()); Some(format!( "Sold {collected} {} to {TRADER_NAME} for {} cr", - ORE.label(), - collected * ORE.bid_cr() + design.name, + collected * design.bid_cr )) } @@ -969,7 +987,10 @@ mod walk { .on_enter(|world: &mut World| { let collected = world.resource::().collected; assert_eq!(Some(check_ledger(world, "docked")), collected); - assert_eq!(inventory(world, PLAYER_ID).count(ORE), collected.unwrap()); + assert_eq!( + inventory(world, PLAYER_ID).count(&ORE.into()), + collected.unwrap() + ); }) .until(frames(1)) .add() diff --git a/examples/playable/shape_bench.rs b/examples/playable/shape_bench.rs index a26999daf..deb7c9bae 100644 --- a/examples/playable/shape_bench.rs +++ b/examples/playable/shape_bench.rs @@ -370,6 +370,7 @@ fn stand_position(index: usize, subjects: usize) -> Vec3 { fn bench_row( game_assets: &GameAssets, sections: &GameSections, + items: &GameItems, roster: Roster, style: StyleId, ) -> ScenarioConfig { @@ -407,7 +408,7 @@ fn bench_row( game_assets.cubemap.clone().into(), ) }; - refuse_broken(&scenario, sections); + refuse_broken(&scenario, sections, items); scenario } @@ -457,13 +458,14 @@ fn refuse_blocked(subject: &Subject, sections: &GameSections) { /// Run the roster through the game's OWN content gate before posing it, the /// `wfc_ships` claim held to by the hand-placed cases too: what stands on the /// bench is a ship the game would accept. -fn refuse_broken(scenario: &ScenarioConfig, sections: &GameSections) { +fn refuse_broken(scenario: &ScenarioConfig, sections: &GameSections, items: &GameItems) { let known = KnownSections::from_configs(sections.iter()); let issues = lint_scenario( scenario, &known, &KnownShipDesigns::default(), &HashSet::from([scenario.id.clone()]), + items, ); let errors: Vec<&str> = issues .iter() @@ -487,6 +489,7 @@ fn load_roster( game_assets: Res, sections: Res, styles: Res, + items: Res, requested: Res, mut roster: ResMut, ) { @@ -502,6 +505,7 @@ fn load_roster( commands.trigger(LoadScenario(bench_row( &game_assets, §ions, + &items, *roster, style, ))); @@ -518,6 +522,7 @@ fn restyle_on_key( game_assets: Res, sections: Res, styles: Res, + items: Res, mut roster: ResMut, ) { if keyboard.just_pressed(KeyCode::KeyL) { @@ -534,6 +539,7 @@ fn restyle_on_key( commands.trigger(LoadScenario(bench_row( &game_assets, §ions, + &items, *roster, style, ))); diff --git a/examples/playable/trade_loop.rs b/examples/playable/trade_loop.rs index 8556edd27..05d0c0353 100644 --- a/examples/playable/trade_loop.rs +++ b/examples/playable/trade_loop.rs @@ -69,12 +69,12 @@ const TRADER_NAME: &str = "Trader"; const TRADER_POSITION: Meters3 = Meters3::new(0.0, 0.0, -55.0); /// What the tender carries at spawn: plates to sell. -const TENDER_STOCK: &[(ItemType, u32)] = &[(ItemType::HullPlate, 4)]; +const TENDER_STOCK: &[(&str, u32)] = &[(ITEM_HULL_PLATE, 4)]; /// The tender's credits at spawn: enough for Rations, short of two Salvaged /// parts after the plates sell. const TENDER_CREDITS: u32 = 100; /// What the trader carries at spawn. -const TRADER_STOCK: &[(ItemType, u32)] = &[(ItemType::Rations, 10), (ItemType::SalvagedParts, 2)]; +const TRADER_STOCK: &[(&str, u32)] = &[("Rations", 10), ("SalvagedParts", 2)]; /// The trader's credits at spawn: enough to buy the plates. const TRADER_CREDITS: u32 = 1_000; @@ -161,7 +161,9 @@ fn trade(game_assets: &GameAssets) -> ScenarioConfig { SpaceshipConfig { controller: SpaceshipController::Player(PlayerControllerConfig::default()), design: ShipDesignSource::Inline(tender()), - inventory: ShipInventoryStock::new(TENDER_STOCK.iter().copied()), + inventory: ShipInventoryStock::new( + TENDER_STOCK.iter().map(|&(id, count)| (id.into(), count)), + ), credits: TENDER_CREDITS, ..default() }, @@ -177,7 +179,9 @@ fn trade(game_assets: &GameAssets) -> ScenarioConfig { controller: SpaceshipController::None, design: ShipDesignSource::Inline(spar()), allegiance: Some(Allegiance::Neutral), - inventory: ShipInventoryStock::new(TRADER_STOCK.iter().copied()), + inventory: ShipInventoryStock::new( + TRADER_STOCK.iter().map(|&(id, count)| (id.into(), count)), + ), lootable: false, credits: TRADER_CREDITS, ..default() @@ -208,7 +212,7 @@ fn trade(game_assets: &GameAssets) -> ScenarioConfig { /// One ship's hold and balance as the walk expects them. #[cfg(feature = "debug")] struct Ledger { - stacks: &'static [(ItemType, u32)], + stacks: &'static [(&'static str, u32)], credits: u32, } @@ -216,11 +220,11 @@ struct Ledger { #[cfg(feature = "debug")] const AFTER_BUY: (Ledger, Ledger) = ( Ledger { - stacks: &[(ItemType::HullPlate, 4), (ItemType::Rations, 1)], + stacks: &[(ITEM_HULL_PLATE, 4), ("Rations", 1)], credits: 92, }, Ledger { - stacks: &[(ItemType::Rations, 9), (ItemType::SalvagedParts, 2)], + stacks: &[("Rations", 9), ("SalvagedParts", 2)], credits: 1_008, }, ); @@ -230,15 +234,11 @@ const AFTER_BUY: (Ledger, Ledger) = ( #[cfg(feature = "debug")] const AFTER_SELL: (Ledger, Ledger) = ( Ledger { - stacks: &[(ItemType::Rations, 1)], + stacks: &[("Rations", 1)], credits: 212, }, Ledger { - stacks: &[ - (ItemType::HullPlate, 4), - (ItemType::Rations, 9), - (ItemType::SalvagedParts, 2), - ], + stacks: &[(ITEM_HULL_PLATE, 4), ("Rations", 9), ("SalvagedParts", 2)], credits: 888, }, ); @@ -256,12 +256,13 @@ fn ship(world: &mut World, id: &str) -> Entity { /// A ship's stacks in item order and its credits, read from the ECS. #[cfg(feature = "debug")] -fn read_ledger(world: &mut World, id: &str) -> (Vec<(ItemType, u32)>, u32) { +fn read_ledger(world: &mut World, id: &str) -> (Vec<(ItemDesignId, u32)>, u32) { let root = ship(world, id); let stacks = world .get::(root) .unwrap_or_else(|| panic!("trade_loop: {id} has no ShipInventory")) .stacks() + .map(|(item, count)| (item.clone(), count)) .collect(); let credits = world .get::(root) @@ -281,6 +282,10 @@ fn check_ledgers(world: &mut World, stage: &str, expected: &(Ledger, Ledger)) { (TENDER_NAME, &tender, &expected.0), (TRADER_NAME, &trader, &expected.1), ] { + let stacks: Vec<(&str, u32)> = stacks + .iter() + .map(|(id, count)| (id.as_str(), *count)) + .collect(); assert_eq!( stacks.as_slice(), want.stacks, @@ -298,16 +303,23 @@ fn check_ledgers(world: &mut World, stage: &str, expected: &(Ledger, Ledger)) { .chain(TRADER_STOCK) .map(|(item, _)| *item) { - let held = |stacks: &[(ItemType, u32)]| { + let held_wanted = |stacks: &[(&str, u32)]| { stacks .iter() .filter(|(held, _)| *held == item) .map(|(_, count)| count) .sum::() }; - let spawned = held(TENDER_STOCK) + held(TRADER_STOCK); + let held_actual = |stacks: &[(ItemDesignId, u32)]| { + stacks + .iter() + .filter(|(held, _)| held.as_str() == item) + .map(|(_, count)| count) + .sum::() + }; + let spawned = held_wanted(TENDER_STOCK) + held_wanted(TRADER_STOCK); assert_eq!( - held(&tender.0) + held(&trader.0), + held_actual(&tender.0) + held_actual(&trader.0), spawned, "trade_loop {stage}: {item:?} made or lost" ); diff --git a/examples/playable/world_clusters.rs b/examples/playable/world_clusters.rs index 4e6cf4293..895ef1a73 100644 --- a/examples/playable/world_clusters.rs +++ b/examples/playable/world_clusters.rs @@ -275,13 +275,14 @@ fn boot_clusters( game_assets: Res, loaded: Res, styles: Res, + items: Res, ) { commands.trigger(LoadScenario(free_play_scenario( &game_assets, SCENARIO_ID, "World Clusters Observer", ))); - commands.insert_resource(clustered_world_config(&loaded, &styles)); + commands.insert_resource(clustered_world_config(&loaded, &styles, &items)); commands.spawn(( Name::new("Observer Key Light"), @@ -473,8 +474,13 @@ fn plan_new_roots( return; }; for (entity, root) in &roots { - let plan = plan_cell(&fields.0, &config.generator.parts, config.input(root.0)) - .unwrap_or_else(|fault| panic!("world clusters: {}: {fault}", root.0)); + let plan = plan_cell( + &fields.0, + &config.generator.parts, + &config.generator.items, + config.input(root.0), + ) + .unwrap_or_else(|fault| panic!("world clusters: {}: {fault}", root.0)); // A scenario swap can despawn the root on this frame. commands.entity(entity).try_insert(RootPlan(plan)); } diff --git a/examples/playable/world_encounters.rs b/examples/playable/world_encounters.rs index 3cdd54f85..06ec46676 100644 --- a/examples/playable/world_encounters.rs +++ b/examples/playable/world_encounters.rs @@ -148,7 +148,7 @@ const HULK_POSITION: Vec3 = Vec3::new(-1_400.0, 0.0, 1_200.0); /// What the hulk carries and holds at spawn, stated here rather than drawn by /// `ship_stock` so beat 14 asserts exact counts. The player spawns with an /// empty hold and no credits, so after the Takes it holds exactly this. -const HULK_STOCK: &[(ItemType, u32)] = &[(ItemType::Rations, 5), (ItemType::SalvagedParts, 2)]; +const HULK_STOCK: &[(&str, u32)] = &[("Rations", 5), ("SalvagedParts", 2)]; const HULK_CREDITS: u32 = 340; #[cfg(feature = "debug")] const HULK_OUTSIDE_LEASH: Vec3 = Vec3::new(-800.0, 0.0, 2_500.0); @@ -379,7 +379,9 @@ fn encounters_scenario(game_assets: &GameAssets, sections: &GameSections) -> Sce dry(ShipRoleType::Civilian, HULK_SEED), HULK_POSITION, SpaceshipConfig { - inventory: ShipInventoryStock::new(HULK_STOCK.iter().copied()), + inventory: ShipInventoryStock::new( + HULK_STOCK.iter().map(|&(id, count)| (id.into(), count)), + ), credits: HULK_CREDITS, ..ai(Allegiance::Enemy) }, @@ -1800,17 +1802,18 @@ fn record_boarding_beat(world: &mut World) { /// The Inventory pane's row for `item` on the docked partner's side. #[cfg(feature = "debug")] -fn partner_row(item: ItemType) -> String { - format!("InventoryRowPartner{item:?}") +fn partner_row(item: &str) -> String { + format!("InventoryRowPartner{item}") } /// A ship's stacks in item order and its credits. #[cfg(feature = "debug")] -fn holdings(world: &World, ship: Entity) -> (Vec<(ItemType, u32)>, u32) { +fn holdings(world: &World, ship: Entity) -> (Vec<(ItemDesignId, u32)>, u32) { let stacks = world .get::(ship) .expect("world_encounters: every ship root has a ShipInventory") .stacks() + .map(|(item, count)| (item.clone(), count)) .collect(); let credits = world .get::(ship) @@ -1828,7 +1831,13 @@ fn assert_loot_seeded(world: &mut World) { let hulk = hull(world, HULK_ID); assert_eq!( holdings(world, hulk), - (HULK_STOCK.to_vec(), HULK_CREDITS), + ( + HULK_STOCK + .iter() + .map(|&(id, count)| (id.into(), count)) + .collect(), + HULK_CREDITS, + ), "world_encounters: beat 14: the boarded hulk does not hold its seeded stock and credits" ); assert_eq!( @@ -1852,8 +1861,9 @@ fn assert_loot_seeded(world: &mut World) { /// the hulk's count of `item` moves, then the next asserts it moved whole: /// a partial Take fails here, not at a deadline. #[cfg(feature = "debug")] -fn take_all(script: Script, item: ItemType, count: u32) -> Script { - let label = item.label(); +fn take_all(script: Script, item: &'static str, count: u32) -> Script { + // The script is built before content loads, so steps name the item id. + let label = item; script .click_named( &format!("beat 14: pick {label} on the hulk's side"), @@ -1877,12 +1887,13 @@ fn take_all(script: Script, item: ItemType, count: u32) -> Script { .on_enter(move |world: &mut World| { let player = hull(world, PLAYER_ID); let hulk = hull(world, HULK_ID); + let item_id = item.into(); let taken = world .get::(player) - .map(|own| own.count(item)); + .map(|own| own.count(&item_id)); let left = world .get::(hulk) - .map(|hold| hold.count(item)); + .map(|hold| hold.count(&item_id)); assert_eq!( (taken, left), (Some(count), Some(0)), @@ -1895,13 +1906,13 @@ fn take_all(script: Script, item: ItemType, count: u32) -> Script { #[cfg(feature = "debug")] fn hulk_count_moved( - item: ItemType, + item: &'static str, count: u32, ) -> Arc { Arc::new(move |world: &World| { staged_hull(world, HULK_ID) .and_then(|hulk| world.get::(hulk)) - .is_some_and(|hold| hold.count(item) != count) + .is_some_and(|hold| hold.count(&item.into()) != count) }) } @@ -1927,7 +1938,10 @@ fn record_loot_beat(world: &mut World) { ); assert_eq!( holdings(world, player), - (HULK_STOCK.to_vec(), HULK_CREDITS), + ( + HULK_STOCK.iter().map(|&(id, count)| (id.into(), count)).collect(), + HULK_CREDITS, + ), "world_encounters: beat 14: the player does not hold exactly the hulk's seeded stock and credits" ); info!("encounters: PASS loot: the Inventory pane took the neutralized Enemy hulk's whole stock {HULK_STOCK:?} and {HULK_CREDITS} cr"); diff --git a/examples/playable/world_features.rs b/examples/playable/world_features.rs index 44d6c5cca..2da12d7e4 100644 --- a/examples/playable/world_features.rs +++ b/examples/playable/world_features.rs @@ -165,13 +165,14 @@ fn boot_observer( game_assets: Res, loaded: Res, styles: Res, + items: Res, ) { commands.trigger(LoadScenario(free_play_scenario( &game_assets, SCENARIO_ID, "World Features Observer", ))); - commands.insert_resource(featured_world_config(&loaded, &styles)); + commands.insert_resource(featured_world_config(&loaded, &styles, &items)); commands.spawn(( Name::new("Observer Key Light"), diff --git a/examples/playable/world_field_clouds.rs b/examples/playable/world_field_clouds.rs index db08a8517..729bb5805 100644 --- a/examples/playable/world_field_clouds.rs +++ b/examples/playable/world_field_clouds.rs @@ -288,6 +288,7 @@ fn boot_clouds( game_assets: Res, loaded: Res, styles: Res, + items: Res, ) { const _: () = assert!( CLOUD_EDGE_SAMPLES.pow(3) <= CLOUD_SAMPLES_MAX, @@ -307,7 +308,7 @@ fn boot_clouds( "World Field Clouds", ))); - let config = featured_world_config(&loaded, &styles); + let config = featured_world_config(&loaded, &styles, &items); commands.insert_resource(CloudJob( AsyncComputeTaskPool::get().spawn(async move { sample_volume(&config) }), )); diff --git a/examples/playable/world_field_slices.rs b/examples/playable/world_field_slices.rs index 5fb15fcb5..3f1154473 100644 --- a/examples/playable/world_field_slices.rs +++ b/examples/playable/world_field_slices.rs @@ -351,6 +351,7 @@ fn request_paint( job: Option>, loaded: Res, styles: Res, + items: Res, ) { let Some(painting) = painting else { return; @@ -359,7 +360,7 @@ fn request_paint( return; } let wanted = *view; - let config = featured_world_config(&loaded, &styles); + let config = featured_world_config(&loaded, &styles, &items); let task = AsyncComputeTaskPool::get().spawn(async move { paint_slice(&config, wanted) }); commands.insert_resource(SliceJob { view: wanted, task }); } diff --git a/examples/playable/world_sector_inspector.rs b/examples/playable/world_sector_inspector.rs index 19932abd6..48020cf9d 100644 --- a/examples/playable/world_sector_inspector.rs +++ b/examples/playable/world_sector_inspector.rs @@ -191,8 +191,9 @@ fn inspector_plugin(app: &mut App, seed: Option) { move |commands: Commands, game_assets: Res, loaded: Res, - styles: Res| { - boot_inspector(commands, game_assets, loaded, styles, seed); + styles: Res, + items: Res| { + boot_inspector(commands, game_assets, loaded, styles, items, seed); }, ); app.add_systems( @@ -227,6 +228,7 @@ fn boot_inspector( game_assets: Res, loaded: Res, styles: Res, + items: Res, seed: Option, ) { let seed = match seed { @@ -247,7 +249,7 @@ fn boot_inspector( let config = WorldConfig { seed, - ..featured_world_config(&loaded, &styles) + ..featured_world_config(&loaded, &styles, &items) }; config .validate() diff --git a/examples/playable/world_ships.rs b/examples/playable/world_ships.rs index bbae9478c..ba6a7a83a 100644 --- a/examples/playable/world_ships.rs +++ b/examples/playable/world_ships.rs @@ -1561,6 +1561,7 @@ fn offer_canister(world: &mut World) { /// [`offer_canister`] does. #[cfg(feature = "debug")] fn offer_canister_to(world: &mut World, ship: Entity) { + let items = world.resource::().clone(); let mut intakes = world.query_filtered::<( &GlobalTransform, &SectionCollider, @@ -1584,7 +1585,7 @@ fn offer_canister_to(world: &mut World, ship: Entity) { let id = world.resource_mut::().mint(); world.spawn(( cargo_canister( - CargoCanister::new(ItemType::HullPlate, 1), + CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 1), Transform::from_translation(at).with_rotation(rotation), ship_velocity - normal * closing, config.canister_mesh.clone(), @@ -1892,7 +1893,7 @@ fn check_wreck_intakes(world: &mut World) { .count(); let taken = world .get::(root) - .map(|inventory| inventory.count(ItemType::HullPlate)) + .map(|inventory| inventory.count(&ITEM_HULL_PLATE.into())) .unwrap_or_default(); assert_eq!( taken, 0, @@ -1973,6 +1974,7 @@ fn check_wreck_hits(world: &mut World) { /// each intake pair's ready flag and the hold's free mass. #[cfg(feature = "debug")] fn diagnose_intake(world: &World) -> String { + let items = world.resource::(); let mut out = String::new(); let Some(mut ships) = world.try_query_filtered::<(Entity, &ShipInventory, &LinearVelocity), ( With, @@ -1986,7 +1988,7 @@ fn diagnose_intake(world: &World) -> String { let _ = write!( out, "hold free {}; ", - kg_text(u64::from(inventory.free_g())) + kg_text(u64::from(inventory.free_g(items))) ); let Some(mut intakes) = world.try_query_filtered::<(Entity, &GlobalTransform, &SectionCollider, &ChildOf), With>() else { return out; @@ -2039,7 +2041,7 @@ fn canister_taken() -> std::sync::Arc bool + Send + Sync> { query .iter(world) .next() - .map(|inventory| inventory.count(ItemType::HullPlate) >= 1) + .map(|inventory| inventory.count(&ITEM_HULL_PLATE.into()) >= 1) }) .unwrap_or(false) }) diff --git a/examples/screenshots/lesson_cargo.rs b/examples/screenshots/lesson_cargo.rs index c71532155..a760372e9 100644 --- a/examples/screenshots/lesson_cargo.rs +++ b/examples/screenshots/lesson_cargo.rs @@ -135,7 +135,7 @@ fn load_scene(mut commands: Commands, game_assets: Res, ships: Res(ship) .unwrap_or_else(|| panic!("lesson_cargo: {id} has no ShipInventory")) - .count(ItemType::HullPlate) + .count(&ITEM_HULL_PLATE.into()) } /// Advance once the warship's hold holds `count` hull plates. @@ -339,7 +339,7 @@ mod walk { .is_some_and(|mut ships| { ships .iter(world) - .any(|inventory| inventory.count(ItemType::HullPlate) == count) + .any(|inventory| inventory.count(&ITEM_HULL_PLATE.into()) == count) }) }) } @@ -509,15 +509,22 @@ mod walk { /// The Jettison emptied the hold into one queued canister of every plate. fn check_jettison(world: &mut World) { assert_eq!(plates(world, PLAYER_ID), 0, "the Jettison emptied the hold"); - let queued: Vec> = world + let queued: Vec> = world .query::<&CargoIntakeEjectionQueue>() .iter(world) - .flat_map(|queue| queue.0.iter().map(|canister| canister.stacks().collect())) + .flat_map(|queue| { + queue.0.iter().map(|canister| { + canister + .stacks() + .map(|(id, count)| (id.as_str(), count)) + .collect() + }) + }) .collect(); info!("lesson_cargo: the intake queues {queued:?}"); assert_eq!( queued, - vec![vec![(ItemType::HullPlate, DERELICT_PLATES)]], + vec![vec![(ITEM_HULL_PLATE, DERELICT_PLATES)]], "the Jettison queued one canister of every plate" ); } @@ -528,16 +535,21 @@ mod walk { !world_has::(world), "the queue is spent" ); - let live: Vec> = world + let live: Vec> = world .query::<&CargoCanister>() .iter(world) - .map(|canister| canister.stacks().collect()) + .map(|canister| { + canister + .stacks() + .map(|(id, count)| (id.as_str(), count)) + .collect() + }) .collect(); info!("lesson_cargo: live canisters {live:?}"); assert_eq!(world.resource::().ejected, 1); assert_eq!( live, - vec![vec![(ItemType::HullPlate, DERELICT_PLATES)]], + vec![vec![(ITEM_HULL_PLATE, DERELICT_PLATES)]], "the intake dropped one canister of every plate" ); } diff --git a/examples/screenshots/lesson_combat_battery.rs b/examples/screenshots/lesson_combat_battery.rs index ee70c1ca8..651babc76 100644 --- a/examples/screenshots/lesson_combat_battery.rs +++ b/examples/screenshots/lesson_combat_battery.rs @@ -329,7 +329,7 @@ fn the_battery( // gauge only exists for a section that carries a `SectionAmmo`, so a // rig with unlimited guns has nothing for this lesson to photograph. player_hull, - ShipInventoryStock::new([(ItemType::PdcRound, PDC_RESERVE)]), + ShipInventoryStock::new([(ITEM_PDC_ROUND.into(), PDC_RESERVE)]), ); let boats: Vec = BOAT_IDS @@ -457,7 +457,7 @@ fn loaded_plus_reserve(world: &mut World) -> (u32, u32) { let reserve = world .get::(player) .expect("every ship root carries a ShipInventory") - .count(ItemType::PdcRound); + .count(&ITEM_PDC_ROUND.into()); (loaded, reserve) } diff --git a/examples/screenshots/lesson_combat_reach.rs b/examples/screenshots/lesson_combat_reach.rs index d99ed0361..c23d6322e 100644 --- a/examples/screenshots/lesson_combat_reach.rs +++ b/examples/screenshots/lesson_combat_reach.rs @@ -364,7 +364,7 @@ fn the_gun_range(game_assets: &GameAssets, ships: &GameShipDesigns) -> ScenarioC SpaceshipController::None, Some(Allegiance::Player), kit::catalog_ship(ships, "block_gunship"), - ShipInventoryStock::new([(ItemType::PdcRound, PDC_RESERVE)]), + ShipInventoryStock::new([(ITEM_PDC_ROUND.into(), PDC_RESERVE)]), ); let boat = hollow::ship( BOAT_ID, diff --git a/examples/screenshots/lesson_generated_world.rs b/examples/screenshots/lesson_generated_world.rs index 566806b21..059acf1a5 100644 --- a/examples/screenshots/lesson_generated_world.rs +++ b/examples/screenshots/lesson_generated_world.rs @@ -162,9 +162,9 @@ struct Wreck { name: String, /// The item the walk takes and gives back: the lowest-ordered item the /// wreck's actual stock carries, picked once here. A generated wreck is no - /// longer guaranteed a [`ItemType::HullPlate`], so Take and Give must work + /// longer guaranteed a [`ITEM_HULL_PLATE`], so Take and Give must work /// from whatever it actually holds, and never reroll it. - item: ItemType, + item: ItemDesignId, /// The two holds' counts of `item` when the wreck was picked: the /// player's, then the wreck's. stock: (u32, u32), @@ -188,7 +188,7 @@ fn the_player(world: &World) -> Option { /// How many of `item` a ship's hold carries. #[cfg(feature = "debug")] -fn count_of(world: &World, ship: Entity, item: ItemType) -> u32 { +fn count_of(world: &World, ship: Entity, item: &ItemDesignId) -> u32 { world .get::(ship) .expect("generated world: a ship carries a ShipInventory") @@ -196,7 +196,7 @@ fn count_of(world: &World, ship: Entity, item: ItemType) -> u32 { } /// Pick the nearest lootable derelict, named the way the world names one, and -/// the lowest-[`ItemType`]-ordered item its actual stock carries: the wreck's +/// the lowest-ordered item its actual stock carries: the wreck's /// mixed loot is no longer guaranteed a hull plate. #[cfg(feature = "debug")] fn pick_the_wreck(world: &mut World) { @@ -224,9 +224,12 @@ fn pick_the_wreck(world: &mut World) { .expect("generated world: a ship carries a ShipInventory") .stacks() .next() - .map(|(item, _)| item) + .map(|(item, _)| item.clone()) .unwrap_or_else(|| panic!("generated world: derelict '{name}' carries no stock")); - let stock = (count_of(world, player, item), count_of(world, entity, item)); + let stock = ( + count_of(world, player, &item), + count_of(world, entity, &item), + ); assert!( stock.1 > 0, "generated world: derelict '{name}' carries no {item:?}" @@ -392,9 +395,10 @@ fn the_note_reads( ) -> std::sync::Arc { std::sync::Arc::new(move |world: &World| { let wreck = world.resource::(); + let items = world.resource::(); let note = format!( "{verb} 1 {} {preposition} {}", - wreck.item.label(), + items.design(&wreck.item).name, wreck.name ); world @@ -415,8 +419,8 @@ fn check_stock(stage: &'static str, moved: i64) -> impl Fn(&mut World) + Send + let wreck = world.resource::().clone(); let player = the_player(world).expect("generated world: exactly one player ship"); let now = ( - count_of(world, player, wreck.item), - count_of(world, wreck.entity, wreck.item), + count_of(world, player, &wreck.item), + count_of(world, wreck.entity, &wreck.item), ); let want = ( (i64::from(wreck.stock.0) + moved) as u32, @@ -440,8 +444,8 @@ fn check_stock(stage: &'static str, moved: i64) -> impl Fn(&mut World) + Send + /// The widget name [`inventory_row`](nova_interface) gives the wreck's /// selected item's row in the `side` column (`"Own"` or `"Partner"`). #[cfg(feature = "debug")] -fn row_name(side: &str, item: ItemType) -> String { - format!("InventoryRow{side}{item:?}") +fn row_name(side: &str, item: &ItemDesignId) -> String { + format!("InventoryRow{side}{}", item.as_str()) } /// Advance once the docked pane lays out a visible row for the wreck's item in @@ -455,8 +459,8 @@ fn row_present( side: &'static str, ) -> std::sync::Arc { std::sync::Arc::new(move |world: &World| { - let item = world.resource::().item; - ui_node_rect(world, &row_name(side, item)).is_some() + let item = world.resource::().item.clone(); + ui_node_rect(world, &row_name(side, &item)).is_some() }) } @@ -501,8 +505,8 @@ fn quick_click_item( script .step(label) .on_enter(move |world: &mut World| { - let item = world.resource::().item; - hover_named(row_name(side, item))(world); + let item = world.resource::().item.clone(); + hover_named(row_name(side, &item))(world); }) .each(|world: &mut World, _, frame| match frame { 2 => press_mouse(MouseButton::Left)(world), diff --git a/examples/screenshots/lesson_interface_ship.rs b/examples/screenshots/lesson_interface_ship.rs index a167ed367..cf0b20007 100644 --- a/examples/screenshots/lesson_interface_ship.rs +++ b/examples/screenshots/lesson_interface_ship.rs @@ -201,11 +201,13 @@ fn hurt_the_turret(world: &mut World) { for mut health in turrets.iter_mut(world) { health.current = health.max * HURT_INTEGRITY; } + let items = world.resource::().clone(); let mut ships = world.query_filtered::<&mut ShipInventory, With>(); for mut inventory in ships.iter_mut(world) { *inventory = ShipInventory::new( + &items, inventory.capacity_g(), - [(ItemType::HullPlate, STOCKED_PLATES)], + [(ITEM_HULL_PLATE.into(), STOCKED_PLATES)], ); } } @@ -232,7 +234,7 @@ fn the_ship_spent_hull_plates() -> std::sync::Arc, sections: Res, ships: Res, + items: Res, ) { let scenario = standoff(&game_assets); - refuse_broken(&scenario, §ions, &ships); + refuse_broken(&scenario, §ions, &ships, &items); commands.trigger(LoadScenario(scenario)); } @@ -174,13 +175,19 @@ fn ship_object( } } -fn refuse_broken(scenario: &ScenarioConfig, sections: &GameSections, ships: &GameShipDesigns) { +fn refuse_broken( + scenario: &ScenarioConfig, + sections: &GameSections, + ships: &GameShipDesigns, + items: &GameItems, +) { let known = KnownSections::from_configs(sections.iter()); let issues = lint_scenario( scenario, &known, &KnownShipDesigns::from_configs(ships.iter()), &HashSet::from([scenario.id.clone()]), + items, ); let errors: Vec<_> = issues .iter() diff --git a/examples/screenshots/loop_helm_orders.rs b/examples/screenshots/loop_helm_orders.rs index 3828c127b..a1c65a359 100644 --- a/examples/screenshots/loop_helm_orders.rs +++ b/examples/screenshots/loop_helm_orders.rs @@ -76,9 +76,10 @@ fn load_orders( game_assets: Res, sections: Res, ships: Res, + items: Res, ) { let scenario = orders(&game_assets); - refuse_broken(&scenario, §ions, &ships); + refuse_broken(&scenario, §ions, &ships, &items); commands.trigger(LoadScenario(scenario)); } @@ -148,13 +149,19 @@ fn crosser() -> ScenarioObjectConfig { } } -fn refuse_broken(scenario: &ScenarioConfig, sections: &GameSections, ships: &GameShipDesigns) { +fn refuse_broken( + scenario: &ScenarioConfig, + sections: &GameSections, + ships: &GameShipDesigns, + items: &GameItems, +) { let known = KnownSections::from_configs(sections.iter()); let issues = lint_scenario( scenario, &known, &KnownShipDesigns::from_configs(ships.iter()), &HashSet::from([scenario.id.clone()]), + items, ); let errors: Vec<_> = issues .iter() diff --git a/examples/screenshots/screenshot_mining_beam.rs b/examples/screenshots/screenshot_mining_beam.rs index 30ec531c9..a66d98db5 100644 --- a/examples/screenshots/screenshot_mining_beam.rs +++ b/examples/screenshots/screenshot_mining_beam.rs @@ -773,7 +773,7 @@ fn mining_script() -> nova_protocol::nova_debug::harness::AutopilotPlugin nova_protocol::nova_debug::harness::AutopilotPlugin(player) .expect("the warship has a hold") - .count(ItemType::StoneOre); + .count(&ITEM_STONE_ORE.into()); let in_canisters: u32 = canisters.iter().sum(); info!( "mining_beam: {flipped} corner(s) flipped, the field lost {}, \ diff --git a/examples/screenshots/screenshot_ship_repair.rs b/examples/screenshots/screenshot_ship_repair.rs index 46cef971a..c5868bd8f 100644 --- a/examples/screenshots/screenshot_ship_repair.rs +++ b/examples/screenshots/screenshot_ship_repair.rs @@ -129,12 +129,14 @@ fn hurt_the_hull(world: &mut World) { hurt, 1, "screenshot_ship_repair: the player ship has one hull" ); + let items = world.resource::().clone(); let mut inventory = world .get_mut::(player) .expect("screenshot_ship_repair: the player ship has a ShipInventory"); *inventory = ShipInventory::new( + &items, inventory.capacity_g(), - [(ItemType::HullPlate, STOCKED_PLATES)], + [(ITEM_HULL_PLATE.into(), STOCKED_PLATES)], ); } @@ -220,7 +222,7 @@ fn check_the_draft(world: &mut World) { let stock = ships .single(world) .expect("screenshot_ship_repair: one player inventory") - .count(ItemType::HullPlate); + .count(&ITEM_HULL_PLATE.into()); assert_eq!( stock, STOCKED_PLATES, "screenshot_ship_repair: plates spent before Repair" diff --git a/examples/shared/world_fixture/clustered.rs b/examples/shared/world_fixture/clustered.rs index 5353d97c8..e2e255130 100644 --- a/examples/shared/world_fixture/clustered.rs +++ b/examples/shared/world_fixture/clustered.rs @@ -149,6 +149,8 @@ const ROCK_KINDS: [&str; 4] = [KIND_ROCK, KIND_METAL, KIND_ICE, KIND_CARBON]; pub struct ClusteredWorld { /// The ship parts every hull is laid out from. pub parts: Arc, + /// The item catalog every hull's hold is laid out from. + pub items: Arc, } /// Equal when both pin one ship-part snapshot. Compares the digest, never the @@ -172,7 +174,13 @@ impl SectorGenerator for ClusteredWorld { } fn generate(&self, input: SectorGenerationInput) -> Result { - Ok(plan_cell(&EnvironmentFields::new(input.seed), &self.parts, input)?.manifest()) + Ok(plan_cell( + &EnvironmentFields::new(input.seed), + &self.parts, + &self.items, + input, + )? + .manifest()) } } @@ -886,6 +894,7 @@ fn halo_nodes(coord: SectorCoord, edge: Meters) -> Vec<[i32; 3]> { pub fn plan_cell( fields: &EnvironmentFields, parts: &ShipPartSnapshot, + items: &GameItems, input: SectorGenerationInput, ) -> Result { let coord = input.coord; @@ -926,6 +935,7 @@ pub fn plan_cell( if let ClusterBody::Hull { yaw, lineage } = member.body { let ship = plan_ship( parts, + items, &civilizations, input.seed, HullSlot { @@ -1073,6 +1083,7 @@ mod tests { digest: ContentCatalogDigest(0), }, &GameStyles(nova_authoring::generation::build_styles()), + &GameItems::new(nova_authoring::generation::build_items()), ) } @@ -1082,8 +1093,13 @@ mod tests { desired_sectors(CLUSTER_HOME, EXAMPLE_ACTIVE_RADIUS) .into_iter() .map(|coord| { - let plan = plan_cell(&fields, &config.generator.parts, config.input(coord)) - .unwrap_or_else(|fault| panic!("{coord}: {fault}")); + let plan = plan_cell( + &fields, + &config.generator.parts, + &config.generator.items, + config.input(coord), + ) + .unwrap_or_else(|fault| panic!("{coord}: {fault}")); (coord, plan) }) .collect() diff --git a/examples/shared/world_fixture/mod.rs b/examples/shared/world_fixture/mod.rs index f59ff56e1..dc7d9bc4e 100644 --- a/examples/shared/world_fixture/mod.rs +++ b/examples/shared/world_fixture/mod.rs @@ -106,12 +106,13 @@ pub fn uniform_world_config() -> WorldConfig { pub fn featured_world_config( loaded: &LoadedSectionPacks, styles: &GameStyles, + items: &GameItems, ) -> WorldConfig { WorldConfig { seed: EXAMPLE_SEED, sector_edge: EXAMPLE_SECTOR_EDGE, active_radius: EXAMPLE_ACTIVE_RADIUS, - generator: NovaLayeredWorld::from_loaded(loaded, styles).unwrap_or_else(|faults| { + generator: NovaLayeredWorld::from_loaded(loaded, styles, items).unwrap_or_else(|faults| { panic!( "world fixture: the loaded catalog does not arm:\n {}", faults @@ -141,8 +142,9 @@ pub const FEATURE_HOME: SectorCoord = SectorCoord::ORIGIN; pub fn clustered_world_config( loaded: &LoadedSectionPacks, styles: &GameStyles, + items: &GameItems, ) -> WorldConfig { - let world = NovaLayeredWorld::from_loaded(loaded, styles).unwrap_or_else(|faults| { + let world = NovaLayeredWorld::from_loaded(loaded, styles, items).unwrap_or_else(|faults| { panic!( "world fixture: the loaded catalog does not arm:\n {}", faults @@ -158,6 +160,7 @@ pub fn clustered_world_config( active_radius: EXAMPLE_ACTIVE_RADIUS, generator: ClusteredWorld { parts: Arc::new(world.parts().clone()), + items: Arc::new(items.clone()), }, } } diff --git a/examples/systems/system_world_resume.rs b/examples/systems/system_world_resume.rs index 18e651302..1548c5443 100644 --- a/examples/systems/system_world_resume.rs +++ b/examples/systems/system_world_resume.rs @@ -337,6 +337,7 @@ fn nearest_asteroid(world: &mut World, from: Vec3) -> (Entity, EntityId) { /// remesh. #[cfg(feature = "debug")] fn inject_fixture(world: &mut World) { + let items = world.resource::().clone(); let player = the_player(world).expect("world_resume: exactly one player ship"); let player_pos = world .get::(player) @@ -362,7 +363,7 @@ fn inject_fixture(world: &mut World) { .get::(rock) .expect("the fixture rock has a transform") .translation(); - let canister_contents = CargoCanister::new(ItemType::IronOre, 3); + let canister_contents = CargoCanister::new(&items, &ITEM_IRON_ORE.into(), 3); let canister = world .spawn(( cargo_canister( @@ -385,14 +386,15 @@ fn inject_fixture(world: &mut World) { let mut inventory = world .get_mut::(player) .expect("the player ship carries ShipInventory"); - let unit_mass = u64::from(ItemType::Rations.mass_g()); - let max_count = (u64::from(inventory.free_g()) / unit_mass) as u32; + let ration_id = "Rations".into(); + let unit_mass = u64::from(items.design(&ration_id).mass_g); + let max_count = (u64::from(inventory.free_g(&items)) / unit_mass) as u32; assert!( max_count >= 1, "world_resume: the line warship has no room for fixture stock (free {}g)", - inventory.free_g() + inventory.free_g(&items) ); - inventory.add(ItemType::Rations, max_count.clamp(1, 5)); + inventory.add(&items, &ration_id, max_count.clamp(1, 5)); } let mut fixture = world.resource_mut::(); @@ -458,7 +460,7 @@ fn record_expected_state(world: &mut World) -> serde_json::Value { .get::(player) .expect("the player ship carries ShipInventory") .stacks() - .map(|(item, count)| serde_json::json!({ "item": format!("{item:?}"), "count": count })) + .map(|(item, count)| serde_json::json!({ "item": item.as_str(), "count": count })) .collect(); let fixture = world.resource::(); let rock_id = fixture @@ -484,7 +486,7 @@ fn record_expected_state(world: &mut World) -> serde_json::Value { .get::(canister_entity) .expect("a mined canister carries CargoCanister") .stacks() - .map(|(item, count)| serde_json::json!({ "item": format!("{item:?}"), "count": count })) + .map(|(item, count)| serde_json::json!({ "item": item.as_str(), "count": count })) .collect(); let sector = world.resource::().0; @@ -746,13 +748,18 @@ fn fixture_transients_expired() -> std::sync::Arc serde_json::Value { let root = nova_assets::storage::worlds_root() .expect("world_resume: the sandboxed CONFIG_ROOT must give a worlds root"); - let (_folder, _lock, header, state) = - open_world(&root, WORLD_SLUG, world.resource::()).unwrap_or_else(|e| { - panic!( - "world_resume: cannot open the just-saved '{WORLD_SLUG}' to read its leave save's \ + let (_folder, _lock, header, state) = open_world( + &root, + WORLD_SLUG, + world.resource::(), + world.resource::(), + ) + .unwrap_or_else(|e| { + panic!( + "world_resume: cannot open the just-saved '{WORLD_SLUG}' to read its leave save's \ transients: {e}" - ) - }); + ) + }); // _lock drops at the end of this function, well before the load phase is // spawned as its own process, so it never contends with the real Load's // own lock on the same folder. @@ -1401,7 +1408,7 @@ fn assert_resumed_state( }); let contents: Vec<_> = canister .stacks() - .map(|(item, count)| serde_json::json!({ "item": format!("{item:?}"), "count": count })) + .map(|(item, count)| serde_json::json!({ "item": item.as_str(), "count": count })) .collect(); assert_eq!( serde_json::Value::Array(contents), @@ -1479,7 +1486,9 @@ fn load_script( std::sync::Mutex, Vec3, f32)>>, >, post_resume_inserts: std::sync::Arc>, - player_snapshot: std::sync::Arc)>>>, + player_snapshot: std::sync::Arc< + std::sync::Mutex)>>, + >, ) -> nova_protocol::nova_debug::harness::AutopilotPlugin { nova_protocol::nova_debug::harness::AutopilotPlugin::::new() .step("world_resume load: reach the main menu") @@ -1662,10 +1671,10 @@ fn load_script( }) .collect(); saved_stock.sort_by(|a, b| a.0.cmp(&b.0)); - snapshot_stock.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0))); + snapshot_stock.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str())); let snapshot_stock: Vec<(String, u32)> = snapshot_stock .into_iter() - .map(|(item, count)| (format!("{item:?}"), count)) + .map(|(item, count)| (item.as_str().to_string(), count)) .collect(); assert_eq!( snapshot_stock, saved_stock, @@ -1899,7 +1908,7 @@ fn run_load() -> bevy::app::AppExit { // player's pre-thaw default here - `Transform` is also what the save // itself records (`SavedPlayer.transform`). let player_snapshot: std::sync::Arc< - std::sync::Mutex)>>, + std::sync::Mutex)>>, > = std::sync::Arc::new(std::sync::Mutex::new(None)); { let recorded = std::sync::Arc::clone(&recorded); @@ -1954,7 +1963,10 @@ fn run_load() -> bevy::app::AppExit { *snapshot = Some(( transform.translation, credits.0, - inventory.stacks().collect(), + inventory + .stacks() + .map(|(item, count)| (item.clone(), count)) + .collect(), )); } } diff --git a/examples/systems/system_world_sectors.rs b/examples/systems/system_world_sectors.rs index 3fdd00653..4f1d45b68 100644 --- a/examples/systems/system_world_sectors.rs +++ b/examples/systems/system_world_sectors.rs @@ -453,6 +453,7 @@ fn streaming_script() -> nova_protocol::nova_debug::harness::AutopilotPlugin(), world.resource::(), + world.resource::(), ); world.insert_resource(config); }) @@ -520,6 +521,7 @@ fn streaming_script() -> nova_protocol::nova_debug::harness::AutopilotPlugin(), world.resource::(), + world.resource::(), ); world.insert_resource(WorldConfig { seed: REPLACEMENT_SEED, @@ -736,6 +738,7 @@ fn report_visit_order(world: &mut World) { &featured_world_config( world.resource::(), world.resource::(), + world.resource::(), ), FEATURE_HOME, ); @@ -838,6 +841,7 @@ fn report_cluster_plan(world: &mut World) { let config = featured_world_config( world.resource::(), world.resource::(), + world.resource::(), ); // One cluster, however many cells own its bodies. Built as id -> every @@ -1018,6 +1022,7 @@ fn report_clearance(world: &mut World) { let config = featured_world_config( world.resource::(), world.resource::(), + world.resource::(), ); let half_edge = config.sector_edge.get() * 0.5; let described = describe_window(FEATURE_HOME, &config); @@ -1591,6 +1596,7 @@ fn report_world_replacement(world: &mut World) { let old_config = featured_world_config( world.resource::(), world.resource::(), + world.resource::(), ); let new_config = world.resource::>().clone(); assert_eq!( diff --git a/tasks/20260930-100831/TASK.md b/tasks/20260930-100831/TASK.md index 7b9ee1315..67823fa27 100644 --- a/tasks/20260930-100831/TASK.md +++ b/tasks/20260930-100831/TASK.md @@ -5,7 +5,7 @@ - TAGS: v0.16.0, items, mods, spike ## User facts -- Item identity for mod-added items and related open-world content is separate from shipped M5 (`tasks/20260926-174806/TASK.md`). The owner wants base and mod items authored as RON content, but wants this **last** in the v0.16.0 sequence and needs more time to choose the model. Do research and small examples first; do not implement a registry yet. +- Item identity for mod-added items and related open-world content is separate from shipped M5 (`tasks/20260926-174806/TASK.md`). The owner first requested research, then approved Plan D implementation in the isolated `open-item-catalog` Sprout. Do not stage, commit or push that work without separate authorization. ## Research and future design gate - First inventory existing `ItemType` consumers and inspect RON-authored registries and mod-merge examples (`ShipDesignId`, `AsteroidKindId`) as possible patterns, not accepted item contracts. Prepare focused examples of base and mod item definitions, conflict/unknown-ID cases, save incompatibility, and a complete player inventory -> canister -> market flow. Compare alternatives and consequences for owner review. @@ -13,11 +13,21 @@ - Define mod-pack ownership, canonical ID namespace/order, validation and duplicate/conflict failure, per-item mass/quantity/category/display, eligible acquisition and consumption, effective-catalog fingerprint, missing-mod behavior, and save identity policy with `tasks/20260925-190156/TASK.md`. Never replace an unknown ID with another good or silently drop stored stock. - Decide shipped-format migration only if needed; remove obsolete unshipped paths rather than maintaining aliases. Author builders then generate/lint content. -## Research (2026-10-10, not approved) -- [RESEARCH.md](RESEARCH.md) inventories `ItemType` consumers, mod merge and save identity on master `467ae965b`, compares four plans and lists owner decisions. The owner favors Plan D (open authored item id, unrelated-pack duplicates refused) for further research. A separate preliminary attribute-only step is not approved. Any open-id plan breaks shipped bare-key stock spelling, including save inventories. Independent papers compare [salvage/refit](PROGRESSION-BUILDER.md) and [charters/economy](PROGRESSION-WORLD.md); [the synthesis](PROGRESSION-SYNTHESIS.md) recommends researching charter goals first, subject to observed play and owner choice. It corrects save/section-thaw and open-ID validation assumptions. Implementation stays deferred. +## Prior research (2026-10-10) +- [RESEARCH.md](RESEARCH.md) inventories `ItemType` consumers, mod merge and save identity on master `467ae965b`, compares four plans and lists owner decisions. The owner favors Plan D (open authored item id, unrelated-pack duplicates refused) for further research. A separate preliminary attribute-only step is not approved. Any open-id plan breaks shipped bare-key stock spelling, including save inventories. Independent papers compare [salvage/refit](PROGRESSION-BUILDER.md) and [charters/economy](PROGRESSION-WORLD.md); [the synthesis](PROGRESSION-SYNTHESIS.md) recommends researching charter goals first, subject to observed play and owner choice. It corrects save/section-thaw and open-ID validation assumptions. The approved implementation contract is below. + +## Approved design (Plan D, 2026-10-10) +- Interface: `ItemDesignId(Arc)` (transparent serde; stock map keys are quoted, `{"HullPlate": 12}`), `ItemDesign { id, name, about, category, mass_g, ask_cr, bid_cr }` as `Content::Item`, and the merged `GameItems` resource. `ItemType` is deleted. Inventory, canister, transfer, trade and jettison take `&GameItems`; the command shell completes `live::ITEM` from it. +- Roles: `ITEM_ROLES` names the ten ids the game itself uses (hull plate Repair; PDC round, rail slug, torpedo Ammo; four ores Raw; `Rations` Food and `SalvagedParts` Parts, the trade goods generated ship holds draw). Base must define each in RON under its category. A dependent mod may replace a role item whole: its category is fixed, its name, about, mass and prices are not. Mod items are generic stock only. +- Rule (`nova_assets::items::item_pack_faults`, shared by the merge and `content lint`): non-empty id, name and about; `mass_g` > 0; `bid_cr` <= `ask_cr`; an ore role fits one canister; one definition per pack. Two packs may define one id only when exactly one depends on the other, and the dependent replaces the whole item. Unrelated packs and dependency cycles refuse both packs in any load order; dependents of a refused mod are refused; a base fault is fatal. +- Lint applies the rule to every walked repo pack at once, so it is stricter than the merge, which sees only enabled packs: repo packs never enabled together still fail, and `--target` checks against every repo pack. Revisit only with a concrete false-positive repro. +- Lint resolves stock ids against the visible catalog; an unknown id is a `ContentIssues` error at lint then load. +- Generation: `NovaLayeredWorld` pins the catalog it armed with; generated stock reads masses from it. +- Saves: `CONTENT_CATALOG_DIGEST_VERSION` 2 and `WORLD_SAVE_FORMAT` 2. Frozen holders expose `item_ids()`. `check_world` (no lock, no cleanup) and `open_world` run one state check, unknown items included; the Load list runs `check_world` per header-good row on the IO pool, shows "Checking saved state" with Load greyed, and replaces a refused row's header with the named reason. Load checks again. A save whose state holds an unknown item fails before writing and keeps the last good save. +- Decided (owner, 2026-10-11): `Rations` and `SalvagedParts` are required roles, `ITEM_RATIONS` and `ITEM_SALVAGED_PARTS`, so a catalog without them fails at lint then load instead of panicking in a sector hold draw. ## Verification - The same effective pack set produces stable IDs and stock independently of load order. Unknown/duplicate IDs and missing required attributes fail at lint then load; a mod item moves through inventory, canister, market and sector lifetime without quantity or mass loss. Incompatible save/catalog changes refuse with a named reason. ## Done when -- Research yields code-backed RON examples, migration alternatives and explicit owner choices; implementation remains deferred until a separate approval after the persistence and player-experience priorities are settled. Any later registry delivery must prove base and mod items through real open-world consumers; no claim of persistence without its reviewed tier. +- The approved registry replaces `ItemType` throughout base and mod content and live consumers; content lint and load reject invalid catalog identities and stock, and incompatible saves remain unavailable in Load without losing the last valid generation. Prove mod items through real inventory, canister and market paths with focused checks. Distinguish source-level and fixture proof from a full flown open-world journey; do not claim delivery before the Sprout's final review and validation. diff --git a/web/src/create/base-content.md b/web/src/create/base-content.md index bc970bc03..6edd89669 100644 --- a/web/src/create/base-content.md +++ b/web/src/create/base-content.md @@ -158,10 +158,10 @@ draws them under. A mod may add its own campaigns, or replace `season_one` by reusing its id - see [Campaigns](../campaigns/). There are no other content kinds - a content file holds `Section`, `Scenario`, -`Campaign`, `Ship`, `Style`, `Lesson` and `UiTheme` items only; factions are -not content, and neither is impact audio, a comms channel or the table a -generated hull is drawn from - all three are engine-owned. The base ship ids -are tabled in [Ships](../ships/#base-ships), the style ids +`Campaign`, `Ship`, `Style`, `Lesson`, `UiTheme` and `Item` items only; +factions are not content, and neither is impact audio, a comms channel or the +table a generated hull is drawn from - all three are engine-owned. The base +ship ids are tabled in [Ships](../ships/#base-ships), the style ids [above](#skin-styles), the theme ids [below](#ui-theme-ids). New Game is base-owned: `new_game_scenario: Some("open_world")` in @@ -302,22 +302,30 @@ the upload itself is fatal. ## The overlay rule How a mod item interacts with this catalog (implemented in -`crates/nova_assets/src/merge.rs`): +`crates/nova_assets/src/merge.rs`, with `Item` collision checks in +`crates/nova_assets/src/items.rs`): - The matching key is the id string per kind - `Section` matches on - `base.id`; `Scenario`, `Campaign`, `Ship`, `Style`, `Lesson` and `UiTheme` - on `id`. Names and file paths never participate. + `base.id`; `Scenario`, `Campaign`, `Ship`, `Style`, `Lesson`, `UiTheme` and + `Item` on `id`. Names and file paths never participate. - Same id as base (or an earlier bundle) = REPLACE, whole item. It is not a field-level patch: an overlay must restate every field it wants to keep. - Sections replace in place, so the editor palette order is preserved. + `Item` replacement requires a one-way dependency chain from the replacing + pack to the original, directly or through other packs. Base counts as a + dependency of every mod. Sections replace in place, so the editor palette + order is preserved. - New id = ADD alongside the catalog. Prefix your own ids with your mod's name (`example_*` in the shipped example mod) so they cannot collide. -- Same id twice within ONE bundle = a conflict: the first item is kept, the - duplicate skipped and logged. +- Same id twice within ONE bundle = a conflict: for kinds other than `Item`, + the first definition is kept and the duplicate is skipped and logged. A + duplicate `Item` id refuses the defining pack. - Merge order is catalog order (base first), then downloaded mods, then a - topological pass so a dependency merges before its dependents - so a mod - overlays base and everything it depends on, and the last independent mod - wins. + topological pass so a dependency merges before its dependents. For kinds + other than `Item`, the last independent mod defining an id wins. For `Item`, + only a dependent pack may replace the whole item. Two unrelated packs, or + two packs in a dependency cycle, defining the same item id are both refused + regardless of load order. Their dependent packs are refused too; a base + refusal stops loading. ## See the source @@ -326,10 +334,10 @@ The builders behind this page live under prototypes, one module per family (`hull`, `controller`, `thruster`, `turret`, `torpedo_bay`, `railgun`, `docking_port`), `styles.rs` the skin styles, `ships/` owns the block hulls, -`scenarios/` groups the training range and the main-menu backdrops, and -`lessons.rs` owns the handbook screens. The two UI themes are built one level -down, in `crates/nova_ui/src/theme/base.rs`, beside the format they are -written in. If this page and the generated RON -ever disagree, the RON is the +`scenarios/` groups the training range and the main-menu backdrops, +`lessons.rs` owns the handbook screens, and `items.rs` owns the item catalog +(generated into `assets/base/items/base.content.ron`). The two UI themes are +built one level down, in `crates/nova_ui/src/theme/base.rs`, beside their +format. If this page and the generated RON ever disagree, the RON is the truth and this page has a bug - the `content_ron_parity` test pins the RON to the builders. diff --git a/web/src/create/mod-files.md b/web/src/create/mod-files.md index 339432f5e..34749bc7c 100644 --- a/web/src/create/mod-files.md +++ b/web/src/create/mod-files.md @@ -2,8 +2,8 @@ A Nova Protocol mod is a folder that contains one bundle manifest, one or more content files, and any art or audio that the mod owns. Content files can define -seven kinds of reusable item: campaigns, scenarios, ship sections, whole -ships, ship skin styles, training lessons, and UI themes. +eight kinds of reusable item: campaigns, scenarios, ship sections, whole +ships, ship skin styles, training lessons, UI themes, and items. Use this page to choose the right file. Then open the detailed reference for the item you want to author. @@ -156,7 +156,7 @@ content is rebalanced, prune the entry. ## Content files Every `*.content.ron` file is a RON list. One file may contain any mix of the -seven item kinds: +eight item kinds: ```ron [ @@ -207,14 +207,42 @@ seven item kinds: metrics: None, roles: (), )), + Item(( + id: "my_mod/survey_core", + name: "Survey core", + about: "A sealed sensor core.", + category: Parts, + mass_g: 5000, + ask_cr: 60, + bid_cr: 45, + )), ] ``` +An `Item` adds a new entry to the item catalog, or replaces one your mod +depends on. Every field is required: `id`, `name` and `about` are not blank; +`category` is one of `Raw`, `Repair`, `Ammo`, `Food`, `Parts`; `mass_g` is +above 0; `bid_cr` is at most `ask_cr`. Any ship's stock, a cargo canister, or +a trade may carry the id, mod items included. The base game must define ten +role ids that the game itself uses: `HullPlate` feeds a repair, the +ammunition feeds a weapon's reload, the ores are mining yield, and generated +ship holds draw the two trade goods `Rations` and `SalvagedParts`. A role id +keeps its category: `HullPlate` is `Repair`; `PdcRound`, `RailSlug` and +`Torpedo` are `Ammo`; `StoneOre`, `IronOre`, `WaterIce` and `CarbonOre` are +`Raw`; `Rations` is `Food`; `SalvagedParts` is `Parts`. A mined ore fits one +cargo canister, so its `mass_g` is at most `200000`. A mod that depends on +the base game may replace a role id whole, with its own `name`, `about`, +`mass_g`, `ask_cr` and `bid_cr`, but not another `category`. A replacement +that breaks either rule is an error. An id two unrelated packs both define is refused in both, whatever +load order; a pack that depends on the id's owner may replace it whole. See +[Scenario objects](../objects/#inventory) for the item table and the stock +format. + Splitting these into `campaign.content.ron`, `scenarios.content.ron`, and `sections.content.ron` is a readability convention, not a loader requirement. Large mods can use one scenario per file and list all of them in `content`. -## The seven content chapters +## The eight content chapters
@@ -234,6 +262,9 @@ Large mods can use one scenario per file and list all of them in `content`. - A [UI theme](../ui-themes/) is the look the whole interface paints in: the palette, the metrics, and the paint of every control in every state. The player picks one in Settings. +- An [item](../objects/#inventory) is one entry in the catalog a ship's stock, + a cargo canister, or a trade can carry - what it is called, what it is for, + what it weighs, and what a trader pays for it. ## Paths and dependencies @@ -249,12 +280,29 @@ the [base content catalog](../base-content/) for reusable base ids and assets. ## Overlay behavior -Content merges by item id: +Content merges by id: - A new id adds a campaign, scenario, section, ship, style, lesson, or theme. -- An id that already exists replaces that whole item. -- A duplicate id inside one bundle is a conflict; the first item is kept. +- An id that already exists replaces that whole entry. +- A duplicate id inside one bundle is a conflict: the load logs it, keeps the + first entry, and loads the rest of the bundle. For sections, the key is `base.id`. For campaigns, scenarios, ships, styles, lessons, and UI themes, the key is `id`. Prefix new ids with your mod id to avoid accidental collisions. + +An item id follows a stricter rule: a new id adds, but an id another pack +already defines is replaced only when your mod depends on that pack, directly +or through others (base counts as a dependency of every mod). Two packs that +share an id with no such one-way chain between them are BOTH refused, in +either load order. A pack that defines one item id twice is refused too. Any +item error refuses the WHOLE pack, not only the item: a mod is switched off +with every mod that depends on it, as for a failing section, and an error in +`base` stops the game. + +`content lint` is stricter than the game here. The game checks only the mods +you enable together. Lint checks every mod in the repository at once, so two +repository mods that share an item id fail lint even when no one enables +both. `content lint --target ` checks your mod against every repository +mod the same way. Lint cannot see mods from outside the repository, so such a +mod can still conflict with yours when the game loads them together. diff --git a/web/src/create/objects.md b/web/src/create/objects.md index 5c70878ca..309a8c6ef 100644 --- a/web/src/create/objects.md +++ b/web/src/create/objects.md @@ -268,9 +268,11 @@ spawn only; it has no runtime action yet. ### Inventory -What the ship carries at spawn: a map from item to stack count. The field is -required; write `inventory: {}` for an empty hold. A count of `0` or the same -item listed twice fails lint and load. The design sets the hold: 100 kg per +What the ship carries at spawn: a map from item id to stack count, with +quoted keys (`inventory: {"HullPlate": 8}`; a bare key fails to load). The +field is required; write `inventory: {}` for an empty hold. A count of `0` or +the same item listed twice fails lint and load. An item id no loaded mod +defines fails lint, then load. The design sets the hold: 100 kg per hull section. Stock heavier than the hold fails lint, then load. Stock shows in the TAB interface's Inventory pane. A Ship pane repair spends hull plates, and every weapon's idle reload draws its matching round from this same stock @@ -284,6 +286,14 @@ seller carries and credits the buyer holds; nothing restocks. An undocked player take canisters back in. Outside a saved Open World (desktop build only), nothing is saved: stock and credits return to their authored values when the scenario loads again. +The table below is the base catalog; a mod adds its own items under its own +ids, and any ship's stock, a canister or a trade may carry them the same way. +The base game must define all ten ids below, each in its category: `HullPlate` +feeds a repair, the ammunition a weapon's reload, the ores mining yield, and +generated ship holds draw `Rations` and `SalvagedParts`. A dependent mod may +replace one with its own mass and prices, never another category; a mod item +of its own is generic authored stock. See [Content files](../mod-files/#content-files). + | Item | Mass | Category | Ask | Bid | | --- | --- | --- | --- | --- | | `HullPlate` | 10 kg | Repair | 40 | 30 | @@ -308,7 +318,7 @@ Serpent and Lance alike - the bay decides the flight. controller: None, allegiance: Some(Neutral), inventory: { - HullPlate: 8, + "HullPlate": 8, }, lootable: true, credits: 0, @@ -323,9 +333,10 @@ credits: 0, Hold: crates/nova_scenario/src/objects/ship_design.rs (`ResolvedShipDesign::cargo_capacity_g` :402, `HULL_SECTION_CARGO_G` :393); overstock lint in crates/nova_scenario/src/lint/ship.rs - `check_object_prototypes`. Item catalog, masses and categories: - crates/nova_gameplay/src/inventory.rs `ItemType::mass_g`, - `ItemType::category`, `ItemType::ask_cr`, `ItemType::bid_cr`. --> + `check_object_prototypes`. Item catalog: crates/nova_gameplay/src/inventory.rs + `ItemDesign` (`mass_g`, `category`, `ask_cr`, `bid_cr`); the base + catalog's values: crates/nova_authoring/src/base_content/items.rs + `item_catalog`. --> ### Section patches diff --git a/web/src/docs-manifest.js b/web/src/docs-manifest.js index d51ccb984..997ce486a 100644 --- a/web/src/docs-manifest.js +++ b/web/src/docs-manifest.js @@ -542,7 +542,7 @@ const CREATE_PAGES = [ toc: true, tags: ["modding", "reference"], summary: - "The mod folder and bundle manifest, content and resource lists, asset schemes, overlay rules, and the content items: campaigns, scenarios, sections, ships, styles, lessons, and UI themes.", + "The mod folder and bundle manifest, content and resource lists, asset schemes, overlay rules, and the content items: campaigns, scenarios, sections, ships, styles, lessons, UI themes, and items.", related: [ "campaigns", "scenarios", @@ -551,13 +551,14 @@ const CREATE_PAGES = [ "styles", "lessons", "ui-themes", + "objects", ], headings: [ "Folder structure", "The bundle manifest", "Balance acknowledgments", "Content files", - "The seven content chapters", + "The eight content chapters", "Paths and dependencies", "Overlay behavior", ], diff --git a/web/src/wiki/commands.md b/web/src/wiki/commands.md index d3845f5b9..6f4420706 100644 --- a/web/src/wiki/commands.md +++ b/web/src/wiki/commands.md @@ -170,8 +170,11 @@ Every one of these is refused until you run `cheats enable`. That command is the + `item give`: crates/nova_console/src/cheats.rs `item_give` (mass from the + loaded `GameItems`); base item masses: crates/nova_authoring/src/ + base_content/items.rs `item_catalog`; Tab + completion from the loaded catalog: crates/nova_console/src/completion.rs + (`GameItems`). --> **Why the mark is one-way.** A run that was ever armed was never clean, whether or not you went on to use a cheat. Marking at the moment you ask, rather than at the moment you benefit, is what makes the mark honest - and it gives the refusal something true to say rather than pretending the command does not exist. @@ -181,7 +184,7 @@ Every one of these is refused until you run `cheats enable`. That command is the **The `ammo` cheats do not touch your hold.** The ordinary idle reload spends matching ammunition from the ship's own inventory (see [Magazines](../combat-weapons/#magazines)); both `ammo` cheats bypass that and act on the magazine directly, spending and returning nothing from stock. -**`item give` fills the hold instead.** The item id is the name a scenario file writes: `HullPlate`, `PdcRound`, `RailSlug`, `Torpedo`, `StoneOre`, `IronOre`, `WaterIce`, `CarbonOre`, `Rations` or `SalvagedParts`, in that exact case. The quantity is a whole number above zero, and the items weigh what they always weigh (a hull plate 10 kg, a PDC round 0.2 kg, a rail slug 20 kg, a torpedo 150 kg). The whole quantity fits the ship's free hold or none of it is added: with 280 kg free, 29 hull plates are refused whole. An unknown ship, item or quantity adds nothing either. The idle reload then loads from the new stock as usual. +**`item give` fills the hold instead.** The item id is any id in the loaded catalog, in the exact case the content authored it, mod items included - Tab completes it for you. An unknown id lists every id the catalog holds. The quantity is a whole number above zero, and each item weighs what the loaded catalog says: in base content a hull plate 10 kg, a PDC round 0.2 kg, a rail slug 20 kg, a torpedo 150 kg, and a mod that replaces an item can change its mass. The whole quantity fits the ship's free hold or none of it is added: with 280 kg free, 29 base hull plates are refused whole. An unknown ship or quantity adds nothing either. The idle reload then loads from the new stock as usual. **This is not the scenario language.** A scenario author has a much larger vocabulary (spawning, despawning, allegiance, forced launches, outcomes). None of it is reachable from this prompt. The catalog above is the whole public surface, and a new scenario action does not appear here unless somebody deliberately adds it. diff --git a/web/src/wiki/getting-started.md b/web/src/wiki/getting-started.md index bdbafa201..c77d0d71c 100644 --- a/web/src/wiki/getting-started.md +++ b/web/src/wiki/getting-started.md @@ -67,7 +67,7 @@ A Load that cannot bring its sectors and transients back within 240 seconds is r ### A refused Load -The Load screen lists every world folder and shows why one cannot be opened, on its row: +The Load screen lists every world folder and shows why one cannot be opened, on its row. It checks each saved state in the background: while a check runs, the row reads `Checking saved state` and **Load** stays greyed. **Load** checks the world again before it starts. | Reason | Shown as | | --- | --- | @@ -75,10 +75,13 @@ The Load screen lists every world folder and shows why one cannot be opened, on | Saved with another save layout | `save format ; this build reads ` | | Saved with other content | `content changed; saved with ` | | Save file missing, corrupt or of another generation | `unreadable: ` | +| The saved state holds an item this game's content does not define | `unreadable: state..ron: unknown item '' held by ` | | A file operation failed | the raw I/O error | | The sectors or transients did not come back in time | `the world did not come back within 240 s: ` | | A saved reference matches two live bodies, or a saved style is not in this game's content | the reason, at once | +The content check runs first. A world saved with a mod item whose mod is now off or changed shows `content changed`, not `unknown item`. + ### Deleting a world Select a world on the Load screen and press **Delete**, then **Delete world** to confirm or **Cancel** to keep it. Delete works on a world that cannot load, too. It removes only the files the game writes (`world.ron`, `world.lock`, the `state..ron` files and the temp files of an interrupted save), then the empty folder. There is no undo. diff --git a/web/src/wiki/interface.md b/web/src/wiki/interface.md index 491c9410b..85ec0d794 100644 --- a/web/src/wiki/interface.md +++ b/web/src/wiki/interface.md @@ -206,8 +206,9 @@ A reserved flight control is refused on the spot - `Space is already bound to fl - + What a rock is made of decides the ore. Every ore weighs 10 kg, so one canister carries up to 20. diff --git a/webmods/the-ledger/CHANGELOG.md b/webmods/the-ledger/CHANGELOG.md index 70ae22718..46787af32 100644 --- a/webmods/the-ledger/CHANGELOG.md +++ b/webmods/the-ledger/CHANGELOG.md @@ -7,6 +7,9 @@ in `the-ledger.bundle.ron`; the portal keeps every published version. ### Scenarios +- **(breaking)** Quote the item ids in ship stock: `inventory: {"PdcRound": + 2000}`. Needs a base game whose items are content; the bare key no longer + loads. - **(breaking)** Replace the old story and chapter ids with six replayable field trials: racing, arena combat, survey, patrol, convoy escort and assault. Add dense belts, planets and moving traffic. Tighten race gates to 50 m, sound diff --git a/webmods/the-ledger/ledger_01_drift_run.content.ron b/webmods/the-ledger/ledger_01_drift_run.content.ron index 3145d7984..6f8a29ae7 100644 --- a/webmods/the-ledger/ledger_01_drift_run.content.ron +++ b/webmods/the-ledger/ledger_01_drift_run.content.ron @@ -33,7 +33,7 @@ controller: Player((input_mapping: {})), initial_velocity: (0.0, 0.0, 0.0), allegiance: Some(Player), - inventory: {PdcRound: 2000}, + inventory: {"PdcRound": 2000}, lootable: false, credits: 2_000, )), diff --git a/webmods/the-ledger/ledger_02_rock_garden.content.ron b/webmods/the-ledger/ledger_02_rock_garden.content.ron index aa49e6a85..33eba7fd8 100644 --- a/webmods/the-ledger/ledger_02_rock_garden.content.ron +++ b/webmods/the-ledger/ledger_02_rock_garden.content.ron @@ -36,7 +36,7 @@ )), initial_velocity: (0.0, 0.0, 0.0), allegiance: Some(Player), - inventory: {PdcRound: 2000}, + inventory: {"PdcRound": 2000}, lootable: false, credits: 2_000, )), diff --git a/webmods/the-ledger/ledger_03_blue_survey.content.ron b/webmods/the-ledger/ledger_03_blue_survey.content.ron index af49d38b3..d831b676a 100644 --- a/webmods/the-ledger/ledger_03_blue_survey.content.ron +++ b/webmods/the-ledger/ledger_03_blue_survey.content.ron @@ -33,7 +33,7 @@ controller: Player((input_mapping: {})), initial_velocity: (0.0, 0.0, 0.0), allegiance: Some(Player), - inventory: {PdcRound: 2000}, + inventory: {"PdcRound": 2000}, lootable: false, credits: 2_000, )), diff --git a/webmods/the-ledger/ledger_04_cold_patrol.content.ron b/webmods/the-ledger/ledger_04_cold_patrol.content.ron index 1aed583e5..d081f5338 100644 --- a/webmods/the-ledger/ledger_04_cold_patrol.content.ron +++ b/webmods/the-ledger/ledger_04_cold_patrol.content.ron @@ -36,7 +36,7 @@ )), initial_velocity: (0.0, 0.0, 0.0), allegiance: Some(Player), - inventory: {PdcRound: 2000}, + inventory: {"PdcRound": 2000}, lootable: false, credits: 2_000, )), diff --git a/webmods/the-ledger/ledger_05_freight_lane.content.ron b/webmods/the-ledger/ledger_05_freight_lane.content.ron index 54361a829..4612cd5b1 100644 --- a/webmods/the-ledger/ledger_05_freight_lane.content.ron +++ b/webmods/the-ledger/ledger_05_freight_lane.content.ron @@ -36,7 +36,7 @@ )), initial_velocity: (0.0, 0.0, 0.0), allegiance: Some(Player), - inventory: {PdcRound: 2000}, + inventory: {"PdcRound": 2000}, lootable: false, credits: 2_000, )), diff --git a/webmods/the-ledger/ledger_06_siege_line.content.ron b/webmods/the-ledger/ledger_06_siege_line.content.ron index dfba25127..b6c71fa54 100644 --- a/webmods/the-ledger/ledger_06_siege_line.content.ron +++ b/webmods/the-ledger/ledger_06_siege_line.content.ron @@ -35,7 +35,7 @@ )), initial_velocity: (0.0, 0.0, 0.0), allegiance: Some(Player), - inventory: {PdcRound: 1000}, + inventory: {"PdcRound": 1000}, lootable: false, credits: 2_000, )), From d75453d2a080d3495e1d5c5de34f04f0dbc9e54a Mon Sep 17 00:00:00 2001 From: Alex Jercan Date: Sun, 11 Oct 2026 01:47:42 +0300 Subject: [PATCH 02/22] Adapt arena ammo reserves to authored items --- crates/nova_interface/src/inventory/app.rs | 21 ++--- .../src/sections/turret_section/firing.rs | 2 +- examples/playable/wfc_arena.rs | 92 ++++++++++++------- examples/playable/wfc_arena/lobby.rs | 29 +++++- examples/playable/wfc_arena/pause.rs | 2 + examples/playable/wfc_arena/result.rs | 2 + 6 files changed, 99 insertions(+), 49 deletions(-) diff --git a/crates/nova_interface/src/inventory/app.rs b/crates/nova_interface/src/inventory/app.rs index fce30d640..76cba8d36 100644 --- a/crates/nova_interface/src/inventory/app.rs +++ b/crates/nova_interface/src/inventory/app.rs @@ -1935,7 +1935,7 @@ pub(crate) fn update_inventory_panel( let own_title = ships.title(pair.own, InventorySideType::Own); let own_cargo = format!( "{} / {}", - kg_text(u64::from(own_inventory.used_g(&items))), + kg_text(u64::from(own_inventory.used_g(items))), kg_text(u64::from(own_inventory.capacity_g())), ); let own = SideView { @@ -2023,8 +2023,8 @@ pub(crate) fn update_inventory_panel( } let visible: Vec<(ItemDesignId, u32)> = stacks .iter() + .filter(|&(item, _)| shown(item)) .cloned() - .filter(|(item, _)| shown(item)) .collect(); if visible.is_empty() { rows.spawn(themed_label( @@ -2037,7 +2037,7 @@ pub(crate) fn update_inventory_panel( let picked = selected .as_ref() .is_some_and(|(side, id)| *side == which && *id == stack.0); - inventory_row(rows, which, stack, picked, &icons, &items); + inventory_row(rows, which, stack, picked, &icons, items); } }); column.drawn = Some(draw); @@ -2210,7 +2210,7 @@ pub(crate) fn update_inventory_panel( let (_, their_stock, lootable, their_cr) = ships.ship(other); match (draft.action.transfer(), draft.action.trade()) { (Some(transfer), _) => plan_item_transfer( - &items, + items, transfer, lootable, &draft.item, @@ -2231,17 +2231,10 @@ pub(crate) fn update_inventory_panel( ItemTransferType::Take => (&partner.title, &own.title), ItemTransferType::Give => (&own.title, &partner.title), }; - transfer_refusal_text( - &items, - refusal, - &draft.item, - from, - to, - &partner.title, - ) + transfer_refusal_text(items, refusal, &draft.item, from, to, &partner.title) }), (None, Some(trade)) => plan_item_trade( - &items, + items, trade, !lootable, &draft.item, @@ -2268,7 +2261,7 @@ pub(crate) fn update_inventory_panel( ItemTradeType::Sell => (&own.title, &partner.title), }; trade_refusal_text( - &items, + items, refusal, &draft.item, seller, diff --git a/crates/nova_ship/src/sections/turret_section/firing.rs b/crates/nova_ship/src/sections/turret_section/firing.rs index 6218af2b4..fc9fe4301 100644 --- a/crates/nova_ship/src/sections/turret_section/firing.rs +++ b/crates/nova_ship/src/sections/turret_section/firing.rs @@ -1046,7 +1046,7 @@ mod tests { app.world() .get::(ship) .unwrap() - .count(ItemType::PdcRound), + .count(&ITEM_PDC_ROUND.into()), 0, "the reload must drain the ship's stock" ); diff --git a/examples/playable/wfc_arena.rs b/examples/playable/wfc_arena.rs index 3032df398..c463734b9 100644 --- a/examples/playable/wfc_arena.rs +++ b/examples/playable/wfc_arena.rs @@ -1053,6 +1053,10 @@ fn player_bindings( /// One combatant: a drafted hull, clad, on its team's colors and in its /// team's line - under the same AI pilot the campaign's raiders fly, or /// under the VIEWER for the one slot `:player` names. +#[expect( + clippy::too_many_arguments, + reason = "one spawn config over the roster slot and both catalogs" +)] fn combatant( slot: usize, seed: u64, @@ -1061,6 +1065,7 @@ fn combatant( place: (usize, usize), binding_overrides: &BTreeMap<(usize, String), Vec>, sections: &GameSections, + items: &GameItems, ) -> ScenarioObjectConfig { let team = &TEAMS[ship.team]; // A resolve error is not ignored: `refuse_broken_ships` lints this spawn @@ -1133,7 +1138,7 @@ fn combatant( }) }, design: ShipDesignSource::Inline(hull), - inventory: spare_ammo(&resolved), + inventory: spare_ammo(&resolved, items), ..Default::default() }), } @@ -1143,10 +1148,10 @@ fn combatant( /// /// The arena's loadout, not a balance rule: the shares sum to the whole hold, /// and the PDC rounds the guns spend fastest take half of it. -const AMMO_HOLD_SHARE: [(ItemType, u32); 3] = [ - (ItemType::PdcRound, 50), - (ItemType::Torpedo, 40), - (ItemType::RailSlug, 10), +const AMMO_HOLD_SHARE: [(&str, u32); 3] = [ + (ITEM_PDC_ROUND, 50), + (ITEM_TORPEDO, 40), + (ITEM_RAIL_SLUG, 10), ]; /// The most full reloads a combatant carries of one ammunition, counted over @@ -1160,21 +1165,22 @@ const SPARE_MAGAZINES: u32 = 2; /// A round no mounted magazine loads is not carried, and a share lighter than /// one item carries none - a small hull's torpedo share does not lift one /// 150 kg torpedo. -fn spare_ammo(hull: &ResolvedShipDesign) -> ShipInventoryStock { +fn spare_ammo(hull: &ResolvedShipDesign, items: &GameItems) -> ShipInventoryStock { let capacity_g = u64::from(hull.cargo_capacity_g()); ShipInventoryStock::new(AMMO_HOLD_SHARE.into_iter().filter_map(|(item, share)| { let magazines: u64 = hull .sections .iter() .filter_map(|section| match (§ion.config.kind, item) { - (SectionKind::Turret(turret), ItemType::PdcRound) => turret.ammunition.rounds(), - (SectionKind::Torpedo(bay), ItemType::Torpedo) => bay.ammunition.rounds(), - (SectionKind::Railgun(lance), ItemType::RailSlug) => lance.ammunition.rounds(), + (SectionKind::Turret(turret), ITEM_PDC_ROUND) => turret.ammunition.rounds(), + (SectionKind::Torpedo(bay), ITEM_TORPEDO) => bay.ammunition.rounds(), + (SectionKind::Railgun(lance), ITEM_RAIL_SLUG) => lance.ammunition.rounds(), _ => None, }) .map(u64::from) .sum(); - let room = capacity_g * u64::from(share) / 100 / u64::from(item.mass_g()); + let item = ItemDesignId::from(item); + let room = capacity_g * u64::from(share) / 100 / u64::from(items.design(&item).mass_g); let count = (magazines * u64::from(SPARE_MAGAZINES)).min(room); let count = u32::try_from(count).expect("a share of a u32 hold counts in a u32"); (count > 0).then_some((item, count)) @@ -1488,6 +1494,7 @@ fn ship_style<'a>(styles: &'a GameStyles, ship: &ShipSpec, run: StyleId<'a>) -> fn arena( game_assets: &GameAssets, sections: &GameSections, + items: &GameItems, styles: &GameStyles, roster: &mut Roster, ) -> ScenarioConfig { @@ -1514,6 +1521,7 @@ fn arena( places[slot], &roster.binding_overrides, sections, + items, )) }) .collect(); @@ -3347,13 +3355,13 @@ mod reserve_tests { use super::*; /// The rounds every finite magazine of `item` holds across `hull`. - fn mounted_rounds(hull: &ResolvedShipDesign, item: ItemType) -> u32 { + fn mounted_rounds(hull: &ResolvedShipDesign, item: &str) -> u32 { hull.sections .iter() .filter_map(|section| match (§ion.config.kind, item) { - (SectionKind::Turret(turret), ItemType::PdcRound) => turret.ammunition.rounds(), - (SectionKind::Torpedo(bay), ItemType::Torpedo) => bay.ammunition.rounds(), - (SectionKind::Railgun(lance), ItemType::RailSlug) => lance.ammunition.rounds(), + (SectionKind::Turret(turret), ITEM_PDC_ROUND) => turret.ammunition.rounds(), + (SectionKind::Torpedo(bay), ITEM_TORPEDO) => bay.ammunition.rounds(), + (SectionKind::Railgun(lance), ITEM_RAIL_SLUG) => lance.ammunition.rounds(), _ => None, }) .sum() @@ -3366,6 +3374,7 @@ mod reserve_tests { #[test] fn a_combatant_carries_a_bounded_reserve_for_its_mounted_magazines() { let sections = GameSections(nova_authoring::generation::build_section_catalog()); + let items = GameItems::new(nova_authoring::generation::build_items()); let tiles = arena_tiles(§ions); let [ai, pilot] = [false, true].map(|player| ShipSpec { team: 0, @@ -3376,7 +3385,16 @@ mod reserve_tests { for seed in 0..6u64 { let spawn = |ship: &ShipSpec| { let hull = combat_hull(&tiles, seed, None, §ions); - combatant(0, seed, hull, ship, (0, 1), &BTreeMap::new(), §ions) + combatant( + 0, + seed, + hull, + ship, + (0, 1), + &BTreeMap::new(), + §ions, + &items, + ) }; let config = spawn(&ai); let ScenarioObjectKind::Spaceship(spaceship) = &config.kind else { @@ -3388,20 +3406,20 @@ mod reserve_tests { let capacity_g = u64::from(resolved.cargo_capacity_g()); let stock = &spaceship.inventory; assert!( - stock.mass_g() <= capacity_g, + stock.mass_g(&items) <= capacity_g, "seed {seed}: {} g of stock in a {capacity_g} g hold", - stock.mass_g() + stock.mass_g(&items) ); for (item, share) in [ - (ItemType::PdcRound, 50), - (ItemType::Torpedo, 40), - (ItemType::RailSlug, 10), + (ITEM_PDC_ROUND, 50), + (ITEM_TORPEDO, 40), + (ITEM_RAIL_SLUG, 10), ] { let spare = u64::from(2 * mounted_rounds(&resolved, item)); - let room = capacity_g * share / 100 / u64::from(item.mass_g()); + let room = capacity_g * share / 100 / u64::from(items.design(&item.into()).mass_g); let carried = stock .stacks() - .find_map(|(held, count)| (held == item).then_some(count)) + .find_map(|(held, count)| (held.as_str() == item).then_some(count)) .unwrap_or(0); assert_eq!( u64::from(carried), @@ -3412,7 +3430,7 @@ mod reserve_tests { assert!( stock .stacks() - .all(|(item, _)| item.category() == ItemCategoryType::Ammo), + .all(|(item, _)| items.design(item).category == ItemCategoryType::Ammo), "seed {seed}: the reserve is ammunition only" ); let flown = spawn(&pilot); @@ -3446,17 +3464,21 @@ mod reserve_tests { #[test] fn a_small_hold_carries_no_torpedo_its_share_cannot_lift() { let sections = GameSections(nova_authoring::generation::build_section_catalog()); + let items = GameItems::new(nova_authoring::generation::build_items()); let hull = small_hull(§ions, 3); assert_eq!(hull.cargo_capacity_g(), 300_000); assert!( - mounted_rounds(&hull, ItemType::Torpedo) > 0, + mounted_rounds(&hull, ITEM_TORPEDO) > 0, "the bays are mounted" ); - let pdc = 750.min(2 * mounted_rounds(&hull, ItemType::PdcRound)); + let pdc = 750.min(2 * mounted_rounds(&hull, ITEM_PDC_ROUND)); assert_eq!( - spare_ammo(&hull).stacks().collect::>(), - [(ItemType::PdcRound, pdc), (ItemType::RailSlug, 1)], + spare_ammo(&hull, &items) + .stacks() + .map(|(item, count)| (item.as_str(), count)) + .collect::>(), + [(ITEM_PDC_ROUND, pdc), (ITEM_RAIL_SLUG, 1)], ); } @@ -3465,9 +3487,17 @@ mod reserve_tests { #[test] fn a_dry_gun_reloads_from_the_reserve_and_conserves_rounds() { let sections = GameSections(nova_authoring::generation::build_section_catalog()); + let items = GameItems::new(nova_authoring::generation::build_items()); let hull = small_hull(§ions, 1); - let mut inventory = ShipInventory::new(hull.cargo_capacity_g(), spare_ammo(&hull).stacks()); - let reserve = inventory.count(ItemType::PdcRound); + let mut inventory = ShipInventory::new( + &items, + hull.cargo_capacity_g(), + spare_ammo(&hull, &items) + .stacks() + .map(|(item, count)| (item.clone(), count)), + ); + let pdc_round = ItemDesignId::from(ITEM_PDC_ROUND); + let reserve = inventory.count(&pdc_round); let (capacity, batch) = hull .sections .iter() @@ -3482,13 +3512,13 @@ mod reserve_tests { rounds: 0, capacity, }; - let mut reload = SectionReload::from_config(batch, ItemType::PdcRound); + let mut reload = SectionReload::from_config(batch, pdc_round.clone()); for _ in 0..capacity { reload.advance(&mut ammo, &mut inventory, batch.delay); } assert_eq!(ammo.rounds, capacity.min(reserve)); - assert_eq!(ammo.rounds + inventory.count(ItemType::PdcRound), reserve); + assert_eq!(ammo.rounds + inventory.count(&pdc_round), reserve); } } diff --git a/examples/playable/wfc_arena/lobby.rs b/examples/playable/wfc_arena/lobby.rs index dde0adaaf..e385aec48 100644 --- a/examples/playable/wfc_arena/lobby.rs +++ b/examples/playable/wfc_arena/lobby.rs @@ -89,6 +89,7 @@ fn load_or_open_lobby( mut commands: Commands, game_assets: Res, sections: Res, + items: Res, styles: Res, requested: Res, mut roster: ResMut, @@ -113,7 +114,14 @@ fn load_or_open_lobby( roster.drafted.clone_from(&drafted_seeds); if std::env::var_os("NOVA_AUTOPILOT").is_some() { - start_match(&mut commands, &game_assets, §ions, &styles, &mut roster); + start_match( + &mut commands, + &game_assets, + §ions, + &items, + &styles, + &mut roster, + ); return; } @@ -146,10 +154,17 @@ fn start_match( commands: &mut Commands, game_assets: &GameAssets, sections: &GameSections, + items: &GameItems, styles: &GameStyles, roster: &mut Roster, ) { - commands.trigger(LoadScenario(arena(game_assets, sections, styles, roster))); + commands.trigger(LoadScenario(arena( + game_assets, + sections, + items, + styles, + roster, + ))); result::begin_match(commands, roster.ships.len()); } @@ -462,6 +477,7 @@ fn on_lobby_action( cameras: Query>, game_assets: Res, sections: Res, + items: Res, styles: Res, model: Option>, mut roster: ResMut, @@ -603,7 +619,14 @@ fn on_lobby_action( for camera in &cameras { commands.entity(camera).despawn(); } - start_match(&mut commands, &game_assets, §ions, &styles, &mut roster); + start_match( + &mut commands, + &game_assets, + §ions, + &items, + &styles, + &mut roster, + ); rebuild = false; } LobbyAction::Quit => { diff --git a/examples/playable/wfc_arena/pause.rs b/examples/playable/wfc_arena/pause.rs index 89a9ea92a..60adfc7e9 100644 --- a/examples/playable/wfc_arena/pause.rs +++ b/examples/playable/wfc_arena/pause.rs @@ -160,6 +160,7 @@ fn on_pause_action( mut next: ResMut>, game_assets: Res, sections: Res, + items: Res, styles: Res, model: Option>, mut roster: ResMut, @@ -173,6 +174,7 @@ fn on_pause_action( commands.trigger(LoadScenario(arena( &game_assets, §ions, + &items, &styles, &mut roster, ))); diff --git a/examples/playable/wfc_arena/result.rs b/examples/playable/wfc_arena/result.rs index 575ed22cc..be0ab1014 100644 --- a/examples/playable/wfc_arena/result.rs +++ b/examples/playable/wfc_arena/result.rs @@ -684,6 +684,7 @@ fn on_result_action( mut physics_time: ResMut>, game_assets: Res, sections: Res, + items: Res, styles: Res, model: Option>, mut roster: ResMut, @@ -700,6 +701,7 @@ fn on_result_action( commands.trigger(LoadScenario(arena( &game_assets, §ions, + &items, &styles, &mut roster, ))); From 20c96d54e30a64fc24c2c4cfd9dffd0a0f48c05d Mon Sep 17 00:00:00 2001 From: Alex Jercan Date: Sun, 11 Oct 2026 09:31:32 +0300 Subject: [PATCH 03/22] Validate stocked arena against authored item catalog --- crates/nova_wfc/Cargo.toml | 6 +- crates/nova_wfc/src/check.rs | 17 ++++-- examples/playable/wfc_arena.rs | 58 +++++++++++++++++-- examples/playable/wfc_ships.rs | 2 +- examples/playable/world_ships.rs | 2 +- .../screenshots/screenshot_generated_ships.rs | 2 +- web/src/create/mod-files.md | 2 +- web/src/docs-manifest.js | 2 +- 8 files changed, 74 insertions(+), 17 deletions(-) diff --git a/crates/nova_wfc/Cargo.toml b/crates/nova_wfc/Cargo.toml index 3c8f4f40b..d31af5f1d 100644 --- a/crates/nova_wfc/Cargo.toml +++ b/crates/nova_wfc/Cargo.toml @@ -18,9 +18,11 @@ nova_ship = { path = "../nova_ship" } # A hull is a `ShipHull` of `SpaceshipSectionConfig`s - the same thing an # authored ship carries, so what comes out is content and not a special case. nova_scenario = { path = "../nova_scenario" } -# `AssetRef` and `Meters3`: the two fields the throwaway scenario `hull_errors` -# wraps a hull in cannot be defaulted. Nothing else here reaches for either. +# `AssetRef`, a field the throwaway scenario `hull_errors` wraps a hull in +# cannot default, and `GameItems`, the catalog `lint_errors` checks stock +# against. nova_gameplay = { path = "../nova_gameplay" } +# `Meters3`, the throwaway scenario's other field that cannot default. nova_events = { path = "../nova_events" } [lints] diff --git a/crates/nova_wfc/src/check.rs b/crates/nova_wfc/src/check.rs index fbaf77c89..6dd73d8c9 100644 --- a/crates/nova_wfc/src/check.rs +++ b/crates/nova_wfc/src/check.rs @@ -227,16 +227,20 @@ pub fn unmated_contacts( /// /// Nothing here re-implements a check: `lint_scenario` is the same function the /// `content lint` gate and the runtime loader run, so what comes back is -/// exactly what the game would refuse. The item catalog is empty: a generated -/// hull carries no stock, so any item it names is reported unknown. -pub fn lint_errors(scenario: &ScenarioConfig, sections: &GameSections) -> Vec { +/// exactly what the game would refuse. Stock is checked against `items`, so an +/// item id the catalog does not hold is reported unknown. +pub fn lint_errors( + scenario: &ScenarioConfig, + sections: &GameSections, + items: &GameItems, +) -> Vec { let known = KnownSections::from_configs(sections.iter()); lint_scenario( scenario, &known, &KnownShipDesigns::default(), &HashSet::from([scenario.id.clone()]), - &GameItems::default(), + items, ) .into_iter() .filter(|issue| issue.severity == LintSeverity::Error) @@ -255,7 +259,8 @@ const LINT_SCENARIO_ID: &str = "generated_hull"; /// The caller that has a hull and no scenario - the editor's Generate verb - /// asks this. It wraps the hull in the smallest scenario that can carry it and /// hands back what [`lint_errors`] found, so a hull is refused by exactly the -/// rules that would refuse it in a mod file. +/// rules that would refuse it in a mod file. The item catalog is empty: a +/// generated hull carries no stock, so any item it names is reported unknown. pub fn hull_errors(hull: &ShipDesign, sections: &GameSections) -> Vec { let spawn = EventActionConfig::SpawnScenarioObject(ScenarioObjectConfig { base: BaseScenarioObjectConfig { @@ -285,7 +290,7 @@ pub fn hull_errors(hull: &ShipDesign, sections: &GameSections) -> Vec { AssetRef::from(LINT_CUBEMAP), ) }; - lint_errors(&scenario, sections) + lint_errors(&scenario, sections, &GameItems::default()) } /// The skybox the throwaway scenario names. The gate does not read it; a diff --git a/examples/playable/wfc_arena.rs b/examples/playable/wfc_arena.rs index c463734b9..b927f5231 100644 --- a/examples/playable/wfc_arena.rs +++ b/examples/playable/wfc_arena.rs @@ -1574,7 +1574,7 @@ fn arena( game_assets.cubemap.clone().into(), ) }; - refuse_broken_ships(&scenario, sections); + refuse_broken_ships(&scenario, sections, items); scenario } @@ -1582,9 +1582,10 @@ fn arena( /// /// Nothing here re-implements a check: `lint_errors` runs `lint_scenario`, the /// same function the `content lint` gate and the runtime loader run, so a -/// clean arena is one the game would accept. -fn refuse_broken_ships(scenario: &ScenarioConfig, sections: &GameSections) { - let errors = lint_errors(scenario, sections); +/// clean arena is one the game would accept. The combatants carry spare +/// ammunition, so the gate reads the loaded item catalog. +fn refuse_broken_ships(scenario: &ScenarioConfig, sections: &GameSections, items: &GameItems) { + let errors = lint_errors(scenario, sections, items); assert!( errors.is_empty(), "wfc_arena: the collapse produced content the game would refuse:\n {}", @@ -3444,6 +3445,55 @@ mod reserve_tests { } } + /// A stocked combatant names real catalog ammunition, so the arena's own + /// content gate has to lint it against the loaded item catalog and accept + /// it. + #[test] + fn a_stocked_arena_clears_the_content_gate() { + let sections = GameSections(nova_authoring::generation::build_section_catalog()); + let items = GameItems::new(nova_authoring::generation::build_items()); + let tiles = arena_tiles(§ions); + let ship = ShipSpec { + team: 0, + style: None, + seed: None, + player: false, + }; + let config = combatant( + 0, + 0, + combat_hull(&tiles, 0, None, §ions), + &ship, + (0, 1), + &BTreeMap::new(), + §ions, + &items, + ); + let ScenarioObjectKind::Spaceship(spaceship) = &config.kind else { + unreachable!("a combatant is a spaceship"); + }; + assert!( + spaceship.inventory.stacks().next().is_some(), + "the combatant carries a reserve" + ); + let scenario = ScenarioConfig { + events: vec![ScenarioEventConfig { + label: None, + name: EventConfig::OnStart, + once: false, + filters: vec![], + actions: vec![EventActionConfig::SpawnScenarioObject(config)], + }], + ..ScenarioConfig::new( + "wfc_arena".to_string(), + "WFC Arena".to_string(), + AssetRef::from("base/textures/cubemap.png"), + ) + }; + + refuse_broken_ships(&scenario, §ions, &items); + } + /// A drafted hull resolved, with its hold cut to `hulls` hull sections and /// every other section kept. fn small_hull(sections: &GameSections, hulls: usize) -> ResolvedShipDesign { diff --git a/examples/playable/wfc_ships.rs b/examples/playable/wfc_ships.rs index e2c0d5fa1..51a888aba 100644 --- a/examples/playable/wfc_ships.rs +++ b/examples/playable/wfc_ships.rs @@ -439,7 +439,7 @@ fn wfc_row( /// those checks - `lint_errors` runs `lint_scenario`, the same function the /// `content lint` gate and the runtime loader run. fn refuse_broken_ships(scenario: &ScenarioConfig, sections: &GameSections) { - let errors = lint_errors(scenario, sections); + let errors = lint_errors(scenario, sections, &GameItems::default()); assert!( errors.is_empty(), "wfc_ships: the collapse produced content the game would refuse:\n {}", diff --git a/examples/playable/world_ships.rs b/examples/playable/world_ships.rs index ba6a7a83a..05449b56d 100644 --- a/examples/playable/world_ships.rs +++ b/examples/playable/world_ships.rs @@ -1196,7 +1196,7 @@ fn scenario( game_assets.cubemap.clone().into(), ) }; - let errors = lint_errors(&scenario, sections); + let errors = lint_errors(&scenario, sections, &GameItems::default()); assert!( errors.is_empty(), "world_ships: a posed ship is content the game would refuse:\n {}", diff --git a/examples/screenshots/screenshot_generated_ships.rs b/examples/screenshots/screenshot_generated_ships.rs index 15f39edc7..3f738bfa9 100644 --- a/examples/screenshots/screenshot_generated_ships.rs +++ b/examples/screenshots/screenshot_generated_ships.rs @@ -333,7 +333,7 @@ fn build_scenario( game_assets.cubemap.clone().into(), ) }; - let errors = lint_errors(&scenario, sections); + let errors = lint_errors(&scenario, sections, &GameItems::default()); assert!( errors.is_empty(), "screenshot_generated_ships: a posed ship is content the game would refuse:\n {}", diff --git a/web/src/create/mod-files.md b/web/src/create/mod-files.md index 34749bc7c..fc03a6890 100644 --- a/web/src/create/mod-files.md +++ b/web/src/create/mod-files.md @@ -242,7 +242,7 @@ Splitting these into `campaign.content.ron`, `scenarios.content.ron`, and `sections.content.ron` is a readability convention, not a loader requirement. Large mods can use one scenario per file and list all of them in `content`. -## The eight content chapters +## The content chapters
diff --git a/web/src/docs-manifest.js b/web/src/docs-manifest.js index 997ce486a..df548eda0 100644 --- a/web/src/docs-manifest.js +++ b/web/src/docs-manifest.js @@ -558,7 +558,7 @@ const CREATE_PAGES = [ "The bundle manifest", "Balance acknowledgments", "Content files", - "The eight content chapters", + "The content chapters", "Paths and dependencies", "Overlay behavior", ], From a2fef321711af14ba754423c0ef22cf7d5aae90c Mon Sep 17 00:00:00 2001 From: Alex Jercan Date: Sun, 11 Oct 2026 11:03:43 +0300 Subject: [PATCH 04/22] Resolve the visited-cell rule once on the sector worker Claim: a cell's frozen record decided what it spawns in two places, on two threads, reconciled by length asserts and clear() calls in materialize_sector. Change: prepare_cell takes the Arc the job holds and returns a PreparedSector { coord, planets: Vec, bodies: PreparedBodies } with PreparedBodies::{Fresh { rocks, ships, arrivals }, Thawed(..)}. Every record travels with what was prepared for it (PreparedRock, PreparedPlanetoid, PreparedFrozenBody built by one constructor), so nothing is paired by position. materialize_sector no longer takes a record; FrozenSectors::consume drops the ledger copy on the materialize frame. Deleted: the two clear() calls, both assert_eq! on lengths, the Vec::new() visited branch, FrozenSector::rocks, FrozenSectors::take, the rocks.next() panics in thaw_record, and PreparedSector::description (the inspector example draws the description on its worker beside the payload). SectorDescription::object_count stays: three description readers use it. Verification: nix develop --command cargo check -p nova_world -p nova_world_base -p nova_scenario -p nova_menu -p nova_interface --all-targets; cargo check --examples --keep-going; cargo test -p nova_world --lib (39 passed). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA --- crates/nova_interface/src/inventory/tests.rs | 1 - crates/nova_world/src/frozen.rs | 100 ++++++------ crates/nova_world/src/generation.rs | 158 ++++++++++++------- crates/nova_world/src/streaming.rs | 119 ++++++-------- crates/nova_world/src/tests.rs | 4 - crates/nova_world/src/tests/frozen.rs | 6 +- examples/playable/world_sector_inspector.rs | 16 +- 7 files changed, 210 insertions(+), 194 deletions(-) diff --git a/crates/nova_interface/src/inventory/tests.rs b/crates/nova_interface/src/inventory/tests.rs index da9fd4ed0..5d3f6c251 100644 --- a/crates/nova_interface/src/inventory/tests.rs +++ b/crates/nova_interface/src/inventory/tests.rs @@ -1777,7 +1777,6 @@ mod generated_wreck { materialize_sector( &mut world.commands(), prepared, - None, &AssetRef::default(), &loaded, ObserverBody { diff --git a/crates/nova_world/src/frozen.rs b/crates/nova_world/src/frozen.rs index 96769ec64..95c676435 100644 --- a/crates/nova_world/src/frozen.rs +++ b/crates/nova_world/src/frozen.rs @@ -31,9 +31,9 @@ use bevy::prelude::*; use nova_events::prelude::{EntityId, Meters3, ScenarioAddressableMarker}; use nova_gameplay::prelude::{CargoCanister, SpaceshipRootMarker, TempEntity, UnsettledBody}; use nova_scenario::prelude::{ - freeze_asteroid, freeze_ore_drop, freeze_ship, thaw_asteroid, thaw_ore_drop, thaw_ship, - AsteroidMarker, FrozenAsteroid, FrozenOreDrop, FrozenShip, MinedOreDrop, PreparedAsteroid, - ScenarioScopedMarker, + freeze_asteroid, freeze_ore_drop, freeze_ship, prepare_frozen_asteroid, thaw_asteroid, + thaw_ore_drop, thaw_ship, AsteroidMarker, FrozenAsteroid, FrozenOreDrop, FrozenShip, + MinedOreDrop, PreparedAsteroid, ScenarioScopedMarker, }; use nova_ship::prelude::{ freeze_canister, freeze_wreck_fragment, thaw_canister, thaw_wreck_fragment, DockedShip, @@ -105,9 +105,13 @@ impl FrozenSectors { self.0.is_empty() } - /// Take a cell's record, for the materialization that spawns it. - pub(crate) fn take(&mut self, coord: SectorCoord) -> Option { - self.0.remove(&coord).map(Arc::unwrap_or_clone) + /// Drop a cell's record on the frame the cell materializes: the job that + /// prepared the cell took the record's bodies with it. Returns how many + /// bodies the record held. + pub(crate) fn consume(&mut self, coord: SectorCoord) -> usize { + self.0 + .remove(&coord) + .map_or(0, |record| record.bodies().len()) } /// Keep the bodies a retiring cell froze this frame. @@ -286,20 +290,27 @@ impl FrozenSector { pub fn is_visited(&self) -> bool { matches!(self, Self::Visited(_)) } +} - /// Each frozen rock, in record order: what a worker prepares the - /// geometry of before the main thread thaws them. - pub(crate) fn rocks(&self) -> impl Iterator { - self.bodies().iter().filter_map(|body| match &body.body { - FrozenBodyType::Asteroid(rock) => Some(rock), - _ => None, - }) - } +/// One frozen body with what a worker prepared for it: a rock's geometry, +/// pristine and, for a carved one, the surface its field meshes to. +/// +/// Built only by [`PreparedFrozenBody::prepare`], so a rock always travels +/// with its geometry and nothing else carries one. +#[derive(Debug)] +pub(crate) struct PreparedFrozenBody { + body: FrozenBody, + geometry: Option, +} - fn into_bodies(self) -> Vec { - match self { - Self::Visited(bodies) | Self::Arrivals(bodies) => bodies, - } +impl PreparedFrozenBody { + /// Prepare `body` for its thaw. PURE, for the cell's worker. + pub(crate) fn prepare(body: FrozenBody) -> Self { + let geometry = match &body.body { + FrozenBodyType::Asteroid(rock) => Some(prepare_frozen_asteroid(rock)), + _ => None, + }; + Self { body, geometry } } } @@ -482,34 +493,22 @@ pub(crate) fn freeze_body( }) } -/// Spawn a record's bodies under `root`, each where and as it froze. -/// -/// `rocks` is the pristine geometry of each frozen rock, prepared on the -/// cell's worker from [`FrozenSector::rocks`], in record order. -/// -/// # Panics -/// -/// When `rocks` does not hold exactly one geometry per frozen rock. A -/// record changes only while its cell is off-window, and every job for an -/// off-window cell is cancelled, so a mismatch is a job prepared against -/// another record. -pub(crate) fn thaw_record( - commands: &mut Commands, - root: Entity, - coord: SectorCoord, - record: FrozenSector, - rocks: Vec, -) { - let mut rocks = rocks.into_iter(); - for frozen in record.into_bodies() { - let FrozenBody { - id, - name, - transform, - visibility, - motion, - body, - } = frozen; +/// Spawn frozen `bodies` under `root`, each where and as it froze, with the +/// geometry the cell's worker prepared for it. +pub(crate) fn thaw_record(commands: &mut Commands, root: Entity, bodies: Vec) { + for prepared in bodies { + let PreparedFrozenBody { + body: + FrozenBody { + id, + name, + transform, + visibility, + motion, + body, + }, + geometry, + } = prepared; if let FrozenBodyType::PendingShip(ship) = body { commands.spawn((PendingSectorShip(*ship), ChildOf(root))); continue; @@ -528,9 +527,8 @@ pub(crate) fn thaw_record( } match body { FrozenBodyType::Asteroid(rock) => { - let Some(geometry) = rocks.next() else { - panic!("nova_world: {coord} thawed more frozen rocks than its job prepared"); - }; + let geometry = + geometry.expect("PreparedFrozenBody::prepare pairs every rock with geometry"); thaw_asteroid(&mut entity, rock, geometry); } FrozenBodyType::Ship(ship) => thaw_ship(&mut entity, *ship), @@ -554,10 +552,6 @@ pub(crate) fn thaw_record( entity.insert(visibility); } } - assert!( - rocks.next().is_none(), - "nova_world: {coord} was prepared with more rock geometry than it froze rocks" - ); } /// The persistent bodies adoption moves between cells, and a replaced world diff --git a/crates/nova_world/src/generation.rs b/crates/nova_world/src/generation.rs index 8dfb2a3fe..4044a2896 100644 --- a/crates/nova_world/src/generation.rs +++ b/crates/nova_world/src/generation.rs @@ -8,20 +8,19 @@ //! held to the same rule: it gets a seed, an edge and a coordinate, and //! nothing else. -use std::{collections::BTreeSet, fmt}; +use std::{collections::BTreeSet, fmt, sync::Arc}; use bevy::{log::info_span, math::Quat}; use nova_events::prelude::{Meters, Meters3, MetersPerSecond3}; use nova_gameplay::prelude::{Allegiance, Fnv32, SeedStream, ShipInventoryStock}; use nova_scenario::prelude::{ - is_asteroid_kind, prepare_asteroid_geometry, prepare_frozen_asteroid, prepare_planet, - AsteroidKindId, PlanetConfig, PreparedAsteroid, PreparedPlanet, SectionSource, ShipDesign, - ASTEROID_GEOMETRIC_FACTOR_MAX, + is_asteroid_kind, prepare_asteroid_geometry, prepare_planet, AsteroidKindId, PlanetConfig, + PreparedAsteroid, PreparedPlanet, SectionSource, ShipDesign, ASTEROID_GEOMETRIC_FACTOR_MAX, }; use crate::{ - frozen::FrozenSector, SectorCoord, SectorFault, SectorGenerationInput, SectorGenerator, - WorldConfig, + frozen::{FrozenSector, PreparedFrozenBody}, + SectorCoord, SectorFault, SectorGenerationInput, SectorGenerator, WorldConfig, }; /// One generated rock, in world meters. @@ -432,33 +431,63 @@ fn canonical_rotation(rotation: Quat) -> String { } /// One sector described, validated and prepared: everything -/// `materialize_sector` needs that a worker can produce. +/// `materialize_sector` needs that a worker can produce, resolved against the +/// cell's frozen record on the worker. /// /// Built only by [`prepare_sector`] and a [`crate::SectorJob`], and the fields -/// are private, so `asteroids` is one prepared rock per description asteroid - -/// or none, for a cell whose frozen record replaces its generated bodies - -/// `planets` one prepared world per description planetoid, and `thawed` one -/// pristine rock per frozen rock of the cell's record, all in order. The -/// two halves are not the same shape: a [`PreparedAsteroid`] is the GEOMETRY -/// only, and the rock's config is rebuilt from the description at spawn, while -/// a [`PreparedPlanet`] carries its config beside its visual. A ship needs no -/// preparation: its inline design's sections are resolved against the section -/// catalog on the main thread, which is the one place the catalog exists. +/// are private. Each record travels WITH what was prepared for it: a planetoid +/// with its surface, a rock with its geometry, a frozen rock with the geometry +/// its record describes. Nothing is paired by position, so the main thread has +/// nothing to count. A ship needs no preparation: its inline design's sections +/// are resolved against the section catalog on the main thread, which is the +/// one place the catalog exists. #[derive(Debug)] pub struct PreparedSector { - pub(crate) description: SectorDescription, - pub(crate) asteroids: Vec, - pub(crate) planets: Vec, - pub(crate) thawed: Vec, + pub(crate) coord: SectorCoord, + /// The description's planetoids. They never freeze, so a visited cell + /// spawns them again beside its record. + pub(crate) planets: Vec, + pub(crate) bodies: PreparedBodies, } impl PreparedSector { - /// The validated description this was prepared from. - pub fn description(&self) -> &SectorDescription { - &self.description + /// The cell this was prepared for. + pub fn coord(&self) -> SectorCoord { + self.coord } } +/// What a cell spawns beside its planetoids, decided once, on the worker, +/// from the cell's frozen record. +#[derive(Debug)] +pub(crate) enum PreparedBodies { + /// A cell never visited: its generated rocks and ships, plus the bodies + /// that arrived into it while nobody held it live. A generated body an + /// arrival overlaps is not removed. + Fresh { + rocks: Vec, + ships: Vec, + arrivals: Vec, + }, + /// A visited cell: its record is the whole truth of it, and the + /// generated rocks and ships are not spawned. + Thawed(Vec), +} + +/// One generated rock with the geometry prepared for it. +#[derive(Debug)] +pub(crate) struct PreparedRock { + pub body: SectorAsteroid, + pub geometry: PreparedAsteroid, +} + +/// One generated planetoid with the surface prepared for it. +#[derive(Debug)] +pub(crate) struct PreparedPlanetoid { + pub body: SectorPlanet, + pub surface: PreparedPlanet, +} + /// The largest hull clearance a generated ship may declare. /// /// A generator measures each hull and declares the radius around its root @@ -859,48 +888,69 @@ pub fn prepare_sector( /// [`prepare_sector`] for a cell that may hold a frozen record. /// -/// A visited cell's record replaces its generated bodies, so its -/// description's rocks are not meshed; the description is still drawn, -/// because its planetoids never freeze. Each rock the record froze is -/// prepared from its record: its pristine geometry, and for a carved one the -/// surface its field meshes to. +/// The one place the record rule is decided: a visited cell's record +/// replaces its generated rocks and ships, so they are neither meshed nor +/// returned; a never-visited cell's arrivals join its generated bodies. The +/// description is drawn either way, because planetoids never freeze. Each +/// frozen rock is prepared from its record: its pristine geometry, and for a +/// carved one the surface its field meshes to. pub(crate) fn prepare_cell( config: WorldConfig, coord: SectorCoord, - record: Option<&FrozenSector>, + record: Option>, ) -> Result { - let visited = record.is_some_and(FrozenSector::is_visited); let _job = info_span!("nova_world::prepare_sector", cell = %coord).entered(); let description = generate_sector(&config, coord)?; - let asteroids = if visited { - Vec::new() - } else { - info_span!("nova_world::prepare_asteroids").in_scope(|| { - description - .asteroids - .iter() - .map(|body| prepare_asteroid_geometry(body.seed, body.radius)) - .collect() - }) - }; + let SectorDescription { + coord, + asteroids, + planets, + ships, + } = description; let planets = info_span!("nova_world::prepare_planets").in_scope(|| { - description - .planets - .iter() - .map(|planet| prepare_planet(planet.config.clone())) - .collect() - }); - let thawed = info_span!("nova_world::prepare_frozen_asteroids").in_scope(|| { - record + planets .into_iter() - .flat_map(FrozenSector::rocks) - .map(prepare_frozen_asteroid) + .map(|body| PreparedPlanetoid { + surface: prepare_planet(body.config.clone()), + body, + }) .collect() }); + let thaw = |bodies: Vec<_>| { + info_span!("nova_world::prepare_frozen_asteroids").in_scope(|| { + bodies + .into_iter() + .map(PreparedFrozenBody::prepare) + .collect::>() + }) + }; + let (visited, arrivals) = match record.map(Arc::unwrap_or_clone) { + None => (None, Vec::new()), + Some(FrozenSector::Visited(bodies)) => (Some(bodies), Vec::new()), + Some(FrozenSector::Arrivals(bodies)) => (None, bodies), + }; + let bodies = match visited { + Some(bodies) => PreparedBodies::Thawed(thaw(bodies)), + None => { + let rocks = info_span!("nova_world::prepare_asteroids").in_scope(|| { + asteroids + .into_iter() + .map(|body| PreparedRock { + geometry: prepare_asteroid_geometry(body.seed, body.radius), + body, + }) + .collect() + }); + PreparedBodies::Fresh { + rocks, + ships, + arrivals: thaw(arrivals), + } + } + }; Ok(PreparedSector { - description, - asteroids, + coord, planets, - thawed, + bodies, }) } diff --git a/crates/nova_world/src/streaming.rs b/crates/nova_world/src/streaming.rs index 12509ec93..49e0d934f 100644 --- a/crates/nova_world/src/streaming.rs +++ b/crates/nova_world/src/streaming.rs @@ -40,7 +40,7 @@ use crate::{ freeze_body, hold_unsettled, thaw_record, DockPolicy, FrozenBody, FrozenSector, FrozenSectors, PersistentBody, SettlingBodies, }, - generation::prepare_cell, + generation::{prepare_cell, PreparedBodies, PreparedPlanetoid, PreparedRock}, PreparedSector, SectorCoord, SectorFault, SectorGenerator, SectorShip, SectorShipConditionType, SectorShipCrew, WorldConfig, }; @@ -162,15 +162,13 @@ pub fn desired_sectors(centre: SectorCoord, radius: i32) -> BTreeSet BTreeSet, texture: &AssetRef, sections: &GameSections, observer: ObserverBody, ) -> Entity { let PreparedSector { - mut description, - asteroids, + coord, planets, - thawed, + bodies, } = prepared; - let coord = description.coord; - if record.as_ref().is_some_and(FrozenSector::is_visited) { - description.asteroids.clear(); - description.ships.clear(); - } - assert_eq!( - description.asteroids.len(), - asteroids.len(), - "nova_world: {coord} was prepared with {} rocks for {} asteroids", - asteroids.len(), - description.asteroids.len() - ); - assert_eq!( - description.planets.len(), - planets.len(), - "nova_world: {coord} was prepared with {} surfaces for {} planetoids", - planets.len(), - description.planets.len() - ); - for ship in &description.ships { + let (rocks, ships, frozen) = match bodies { + PreparedBodies::Fresh { + rocks, + ships, + arrivals, + } => (rocks, ships, arrivals), + PreparedBodies::Thawed(frozen) => (Vec::new(), Vec::new(), frozen), + }; + for ship in &ships { require_resolved(ship, sections); } - let objects = description.object_count(); + let objects = planets.len() + rocks.len() + ships.len() + frozen.len(); let root = commands .spawn(( base_scenario_object(&BaseScenarioObjectConfig { @@ -258,7 +239,7 @@ pub fn materialize_sector( )) .id(); - for (body, rock) in description.asteroids.into_iter().zip(asteroids) { + for PreparedRock { body, geometry } in rocks { let mut entity = commands.spawn(( base_scenario_object(&BaseScenarioObjectConfig { id: body.id.clone(), @@ -280,16 +261,16 @@ pub fn materialize_sector( seed: Some(body.seed), }, body.seed, - rock, + geometry, ); } - for (planet, surface) in description.planets.into_iter().zip(planets) { + for PreparedPlanetoid { body, surface } in planets { let mut entity = commands.spawn(( base_scenario_object(&BaseScenarioObjectConfig { - id: planet.id.clone(), - name: planet.id.clone(), - position: planet.position, + id: body.id.clone(), + name: body.id.clone(), + position: body.position, rotation: Quat::IDENTITY, }), ChildOf(root), @@ -297,7 +278,7 @@ pub fn materialize_sector( planet_scenario_object_prepared(&mut entity, surface); } - for ship in description.ships { + for ship in ships { if observer.overlaps(&ship) { debug!( "nova_world: holding ship '{}' in {coord}: the observer overlaps its {:.0} m \ @@ -311,18 +292,12 @@ pub fn materialize_sector( } } - match record { - Some(record) => { - trace!( - "nova_world: thawing {} frozen body(ies) into {coord}", - record.bodies().len() - ); - thaw_record(commands, root, coord, record, thawed); - } - None => assert!( - thawed.is_empty(), - "nova_world: {coord} was prepared with frozen rocks but holds no frozen record" - ), + if !frozen.is_empty() { + trace!( + "nova_world: thawing {} frozen body(ies) into {coord}", + frozen.len() + ); + thaw_record(commands, root, frozen); } trace!("nova_world: materialized {coord} with {objects} object(s)"); @@ -546,11 +521,12 @@ pub struct SectorJob { impl SectorJob { /// Start one cell's preparation on `AsyncComputeTaskPool`. /// - /// `record` is the cell's frozen record, if it holds one: a visited cell's - /// description rocks are not meshed, and every frozen rock's geometry is - /// prepared from its record. A record changes only while its cell is off-window, and + /// `record` is the cell's frozen record, if it holds one. The worker + /// resolves the cell against it (see [`PreparedBodies`]) and the payload + /// carries the record's bodies, so the materialization only drops the + /// ledger's copy. A record changes only while its cell is off-window, and /// [`retire_sectors`] cancels every job for an off-window cell, so the - /// record a job was started from is the one its materialization takes. + /// record a job was started from is the one its cell comes back from. /// /// The config is MOVED into the task rather than read from the resource /// when it finishes: a job answers the question it was asked, and a dial @@ -563,8 +539,8 @@ impl SectorJob { coord: SectorCoord, record: Option>, ) -> Self { - let task = AsyncComputeTaskPool::get() - .spawn(async move { prepare_cell(config, coord, record.as_deref()) }); + let task = + AsyncComputeTaskPool::get().spawn(async move { prepare_cell(config, coord, record) }); Self { coord, task } } } @@ -841,20 +817,15 @@ pub fn materialize_ready_sector( return; }; - let coord = prepared.description().coord; + let coord = prepared.coord(); let texture = AssetRef::::Path(ASTEROID_TEXTURE_PATH.to_string()); let Ok((transform, envelope)) = observer.single() else { panic!("nova_world: {}", SectorFault::AbsentObserver); }; let observer = ObserverBody::read(transform, envelope); - materialize_sector( - &mut commands, - prepared, - frozen.take(coord), - &texture, - §ions, - observer, - ); + // The job carried the record's bodies; the ledger's copy is spent. + frozen.consume(coord); + materialize_sector(&mut commands, prepared, &texture, §ions, observer); stats.materialized += 1; if desired diff --git a/crates/nova_world/src/tests.rs b/crates/nova_world/src/tests.rs index b0c5c6291..8b1d35c68 100644 --- a/crates/nova_world/src/tests.rs +++ b/crates/nova_world/src/tests.rs @@ -660,7 +660,6 @@ fn a_materialized_asteroid_opts_into_gravity() { let root = materialize_sector( &mut world.commands(), prepared, - None, &AssetRef::default(), &GameSections::default(), ObserverBody { @@ -806,7 +805,6 @@ fn materialize_one_ship(world: &mut World, offset: f32) -> Entity { let root = materialize_sector( &mut world.commands(), prepared, - None, &AssetRef::default(), &test_sections(), observer_at(config.input(SectorCoord::ORIGIN), offset), @@ -925,7 +923,6 @@ fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_b materialize_sector( &mut world.commands(), prepared, - None, &AssetRef::default(), &test_sections(), observer_at(config.input(SectorCoord::ORIGIN), 5_000.0), @@ -1032,7 +1029,6 @@ fn a_ship_design_the_loaded_sections_do_not_resolve_is_refused_at_spawn() { materialize_sector( &mut world.commands(), prepared, - None, &AssetRef::default(), &test_sections(), ObserverBody { diff --git a/crates/nova_world/src/tests/frozen.rs b/crates/nova_world/src/tests/frozen.rs index 617234c74..a4f9e6f3d 100644 --- a/crates/nova_world/src/tests/frozen.rs +++ b/crates/nova_world/src/tests/frozen.rs @@ -77,8 +77,9 @@ fn materialize( observer: ObserverBody, ) -> Entity { let config = world.resource::>().clone(); - let record = world.resource_mut::().take(coord); - let prepared = prepare_cell(config, coord, record.as_ref()).expect("the cell prepares"); + let record = world.resource::().shared(coord); + let prepared = prepare_cell(config, coord, record).expect("the cell prepares"); + world.resource_mut::().consume(coord); let sections = world .get_resource::() .cloned() @@ -86,7 +87,6 @@ fn materialize( let root = materialize_sector( &mut world.commands(), prepared, - record, &AssetRef::Path(nova_assets::prelude::ASTEROID_TEXTURE_PATH.to_string()), §ions, observer, diff --git a/examples/playable/world_sector_inspector.rs b/examples/playable/world_sector_inspector.rs index 48020cf9d..960e8ccb5 100644 --- a/examples/playable/world_sector_inspector.rs +++ b/examples/playable/world_sector_inspector.rs @@ -141,7 +141,10 @@ enum InspectorCameraType { /// The cell being prepared. Replaced, and so cancelled, by a newer request. struct SectorRequest { coord: SectorCoord, - task: Task>, + /// The cell's description, for the readout, beside the payload that + /// spawns it. The payload no longer carries the description, so the + /// worker draws the cell twice; an inspector step affords it. + task: Task>, } /// Everything the inspector knows about what it shows and what it waits for. @@ -345,7 +348,11 @@ fn request_sector( let config = config.clone(); inspector.request = Some(SectorRequest { coord, - task: AsyncComputeTaskPool::get().spawn(async move { prepare_sector(config, coord) }), + task: AsyncComputeTaskPool::get().spawn(async move { + let description = generate_sector(&config, coord)?; + let prepared = prepare_sector(config, coord)?; + Ok((description, prepared)) + }), }); } @@ -447,7 +454,7 @@ fn collect_sector( }; let coord = request.coord; inspector.request = None; - let prepared = result.unwrap_or_else(|fault| { + let (description, prepared) = result.unwrap_or_else(|fault| { panic!( "world_sector_inspector: seed {} cell {coord}: {fault}", world.0.seed @@ -457,7 +464,6 @@ fn collect_sector( panic!("world_sector_inspector: no single scenario camera to observe {coord}"); }; - let description = prepared.description(); let canonical = description.canonical(); let rocks = description.asteroids().len(); let planets = description.planets().len(); @@ -485,7 +491,7 @@ fn collect_sector( commands.entity(root).despawn(); } let texture: AssetRef = game_assets.asteroid_texture.clone().into(); - let root = materialize_sector(&mut commands, prepared, None, &texture, §ions, observer); + let root = materialize_sector(&mut commands, prepared, &texture, §ions, observer); info!( "inspector: live {coord} of seed {}: {rocks} rocks, {planets} planetoids, {ships} ships", world.0.seed From a2f14958d26dcfd2f2c9d65565e134441d05c2c7 Mon Sep 17 00:00:00 2001 From: Alex Jercan Date: Sun, 11 Oct 2026 11:07:18 +0300 Subject: [PATCH 05/22] Save a held ship by id, not by its manifest entry Claim: FrozenBodyType::PendingShip(Box) wrote a generator record, inline design included, into the world save, so every SectorShip change was a save format change and a generator record carried serde for that alone. Change: FrozenBodyType::PendingShip { id: String }. The cell's worker regenerates the cell anyway, and PreparedFrozenBody::prepare resolves the id against the regenerated ships into PreparedFrozenBody::HeldShip; a missing id panics with the coord and id (the save's catalog digest pins the generator, so it is an assembly fault). check_saved_items has no stock to check on a held ship; the pending-ship fixture and case leave the item-refusal test. SectorShip and SectorShipCrew drop their serde derives: no save holds one. WORLD_SAVE_FORMAT 2 -> 3 (unshipped; no migration, no changelog entry). Verification: nix develop --command cargo check -p nova_world -p nova_world_base -p nova_scenario -p nova_menu -p nova_interface --all-targets; cargo check --examples --keep-going; cargo test -p nova_world --lib (39 passed); cargo test -p nova_world_base --lib (91 passed). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA --- crates/nova_world/src/frozen.rs | 104 +++++++++++++++-------- crates/nova_world/src/generation.rs | 22 +++-- crates/nova_world/src/tests/frozen.rs | 2 +- crates/nova_world_base/src/save/mod.rs | 11 ++- crates/nova_world_base/src/save/tests.rs | 53 ++---------- 5 files changed, 91 insertions(+), 101 deletions(-) diff --git a/crates/nova_world/src/frozen.rs b/crates/nova_world/src/frozen.rs index 95c676435..990b1bca0 100644 --- a/crates/nova_world/src/frozen.rs +++ b/crates/nova_world/src/frozen.rs @@ -293,24 +293,52 @@ impl FrozenSector { } /// One frozen body with what a worker prepared for it: a rock's geometry, -/// pristine and, for a carved one, the surface its field meshes to. +/// pristine and, for a carved one, the surface its field meshes to; a held +/// ship's manifest entry, regenerated. /// /// Built only by [`PreparedFrozenBody::prepare`], so a rock always travels -/// with its geometry and nothing else carries one. +/// with its geometry, a held ship always with its entry, and nothing else +/// carries either. #[derive(Debug)] -pub(crate) struct PreparedFrozenBody { - body: FrozenBody, - geometry: Option, +pub(crate) enum PreparedFrozenBody { + /// A body that thaws where it froze. + Body { + body: FrozenBody, + geometry: Option, + }, + /// A ship still held back, to spawn once the observer is clear of it. + HeldShip(SectorShip), } impl PreparedFrozenBody { - /// Prepare `body` for its thaw. PURE, for the cell's worker. - pub(crate) fn prepare(body: FrozenBody) -> Self { - let geometry = match &body.body { - FrozenBodyType::Asteroid(rock) => Some(prepare_frozen_asteroid(rock)), - _ => None, - }; - Self { body, geometry } + /// Prepare `body` of `coord` for its thaw, `ships` being the cell's + /// regenerated ships. PURE, for the cell's worker. + /// + /// # Panics + /// + /// When a held ship's id names none of `ships`. The generator is pinned + /// by the catalog digest the save carries, so the cell cannot regenerate + /// without it: an assembly fault, not a runtime condition. + pub(crate) fn prepare(coord: SectorCoord, body: FrozenBody, ships: &[SectorShip]) -> Self { + match &body.body { + FrozenBodyType::PendingShip { id } => { + let Some(ship) = ships.iter().find(|ship| ship.id == *id) else { + panic!( + "nova_world: {coord} froze the held ship '{id}' but regenerates no ship \ + of that id" + ); + }; + Self::HeldShip(ship.clone()) + } + FrozenBodyType::Asteroid(rock) => { + let geometry = Some(prepare_frozen_asteroid(rock)); + Self::Body { body, geometry } + } + _ => Self::Body { + body, + geometry: None, + }, + } } } @@ -399,9 +427,14 @@ pub enum FrozenBodyType { Asteroid(FrozenAsteroid), /// A ship, with its hold, damage, surviving sections, ammunition and AI. Ship(Box), - /// A ship held back because the observer overlapped it, still a manifest - /// entry. - PendingShip(Box), + /// A ship held back because the observer overlapped it, by the id of + /// its manifest entry. The cell regenerates the entry on its worker: + /// the catalog digest a save carries pins the generator, so the same id + /// names the same ship. + PendingShip { + /// The held ship's generated id. + id: String, + }, /// A loose cargo canister with its own runtime id. Canister(FrozenCanister), /// Structure severed from a ship. @@ -441,14 +474,14 @@ pub(crate) fn freeze_body( ) -> Result { let body = world.entity(entity); if let Some(held) = body.get::() { - let ship = held.ship().clone(); + let id = held.ship().id.clone(); return Ok(FrozenBody { - id: Some(EntityId::new(ship.id.clone())), + id: Some(EntityId::new(id.clone())), name: None, transform: Transform::IDENTITY, visibility: None, motion: None, - body: FrozenBodyType::PendingShip(Box::new(ship)), + body: FrozenBodyType::PendingShip { id }, }); } let name = body.get::().map(ToString::to_string); @@ -497,22 +530,23 @@ pub(crate) fn freeze_body( /// geometry the cell's worker prepared for it. pub(crate) fn thaw_record(commands: &mut Commands, root: Entity, bodies: Vec) { for prepared in bodies { - let PreparedFrozenBody { - body: - FrozenBody { - id, - name, - transform, - visibility, - motion, - body, - }, + let ( + FrozenBody { + id, + name, + transform, + visibility, + motion, + body, + }, geometry, - } = prepared; - if let FrozenBodyType::PendingShip(ship) = body { - commands.spawn((PendingSectorShip(*ship), ChildOf(root))); - continue; - } + ) = match prepared { + PreparedFrozenBody::HeldShip(ship) => { + commands.spawn((PendingSectorShip(ship), ChildOf(root))); + continue; + } + PreparedFrozenBody::Body { body, geometry } => (body, geometry), + }; let mut entity = commands.spawn(( ScenarioScopedMarker, transform, @@ -540,7 +574,9 @@ pub(crate) fn thaw_record(commands: &mut Commands, root: Entity, bodies: Vec
 {
                 entity.insert(thaw_ore_drop(drop));
             }
-            FrozenBodyType::PendingShip(_) => unreachable!("spawned above"),
+            FrozenBodyType::PendingShip { .. } => {
+                unreachable!("PreparedFrozenBody::prepare resolves a held ship to HeldShip")
+            }
         }
         // After the owner's thaw: a spawn bundle seeds the velocity and the
         // visibility it was authored with, and the body keeps the ones it
diff --git a/crates/nova_world/src/generation.rs b/crates/nova_world/src/generation.rs
index 4044a2896..c1e59e47b 100644
--- a/crates/nova_world/src/generation.rs
+++ b/crates/nova_world/src/generation.rs
@@ -198,8 +198,6 @@ impl ShipRoleType {
 
 /// Who flies an intact generated ship, and where.
 #[derive(Clone, Debug)]
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
 pub struct SectorShipCrew {
     /// The side it fights for: its civilization's side.
     pub allegiance: Allegiance,
@@ -214,8 +212,6 @@ pub struct SectorShipCrew {
 
 /// One generated ship: a hull, its civilization, and its crew if it works.
 #[derive(Clone, Debug)]
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
-#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
 pub struct SectorShip {
     /// The ship's scenario id, prefixed with its owning cell's slug.
     pub id: String,
@@ -891,9 +887,10 @@ pub fn prepare_sector(
 /// The one place the record rule is decided: a visited cell's record
 /// replaces its generated rocks and ships, so they are neither meshed nor
 /// returned; a never-visited cell's arrivals join its generated bodies. The
-/// description is drawn either way, because planetoids never freeze. Each
-/// frozen rock is prepared from its record: its pristine geometry, and for a
-/// carved one the surface its field meshes to.
+/// description is drawn either way, because planetoids never freeze and a
+/// held ship's record names only its id. Each frozen rock is prepared from
+/// its record: its pristine geometry, and for a carved one the surface its
+/// field meshes to.
 pub(crate) fn prepare_cell(
     config: WorldConfig,
     coord: SectorCoord,
@@ -916,11 +913,11 @@ pub(crate) fn prepare_cell(
             })
             .collect()
     });
-    let thaw = |bodies: Vec<_>| {
-        info_span!("nova_world::prepare_frozen_asteroids").in_scope(|| {
+    let thaw = |bodies: Vec<_>, ships: &[SectorShip]| {
+        info_span!("nova_world::prepare_frozen_bodies").in_scope(|| {
             bodies
                 .into_iter()
-                .map(PreparedFrozenBody::prepare)
+                .map(|body| PreparedFrozenBody::prepare(coord, body, ships))
                 .collect::>()
         })
     };
@@ -930,8 +927,9 @@ pub(crate) fn prepare_cell(
         Some(FrozenSector::Arrivals(bodies)) => (None, bodies),
     };
     let bodies = match visited {
-        Some(bodies) => PreparedBodies::Thawed(thaw(bodies)),
+        Some(bodies) => PreparedBodies::Thawed(thaw(bodies, &ships)),
         None => {
+            let arrivals = thaw(arrivals, &ships);
             let rocks = info_span!("nova_world::prepare_asteroids").in_scope(|| {
                 asteroids
                     .into_iter()
@@ -944,7 +942,7 @@ pub(crate) fn prepare_cell(
             PreparedBodies::Fresh {
                 rocks,
                 ships,
-                arrivals: thaw(arrivals),
+                arrivals,
             }
         }
     };
diff --git a/crates/nova_world/src/tests/frozen.rs b/crates/nova_world/src/tests/frozen.rs
index a4f9e6f3d..0651c42f6 100644
--- a/crates/nova_world/src/tests/frozen.rs
+++ b/crates/nova_world/src/tests/frozen.rs
@@ -814,7 +814,7 @@ fn a_mined_fought_and_looted_cell_returns_as_it_was_left() {
         record
             .bodies()
             .iter()
-            .any(|body| matches!(body.body(), FrozenBodyType::PendingShip(_))),
+            .any(|body| matches!(body.body(), FrozenBodyType::PendingShip { .. })),
         "the held ship froze held"
     );
 
diff --git a/crates/nova_world_base/src/save/mod.rs b/crates/nova_world_base/src/save/mod.rs
index 9d20e3a46..cc1812f3c 100644
--- a/crates/nova_world_base/src/save/mod.rs
+++ b/crates/nova_world_base/src/save/mod.rs
@@ -57,7 +57,7 @@ pub use transients::{
 /// Bump it with any change to the save layout, or to what the generator
 /// builds from a seed: an old save holds only the sectors the player changed
 /// and regenerates the rest, so another generator would change it silently.
-pub const WORLD_SAVE_FORMAT: u32 = 2;
+pub const WORLD_SAVE_FORMAT: u32 = 3;
 
 /// The most characters a world name has, after trimming.
 const WORLD_NAME_MAX: usize = 32;
@@ -362,8 +362,8 @@ fn read_state(
     Ok(state)
 }
 
-/// Every item id the saved state holds is in `items`: the player's, every
-/// ledger body's and every held-back ship's stock. A save checks before it
+/// Every item id the saved state holds is in `items`: the player's and every
+/// ledger body's. A save checks before it
 /// writes; Load checks after the header's catalog digest has matched. A
 /// changed or missing mod normally fails at that earlier digest check.
 fn check_saved_items(state: &WorldSaveState, items: &GameItems) -> Result<(), String> {
@@ -381,9 +381,8 @@ fn check_saved_items(state: &WorldSaveState, items: &GameItems) -> Result<(), St
             let found = match body.body() {
                 FrozenBodyType::Asteroid(rock) => unknown(&mut rock.item_ids()),
                 FrozenBodyType::Ship(ship) => unknown(&mut ship.item_ids()),
-                FrozenBodyType::PendingShip(ship) => {
-                    unknown(&mut ship.stock.stacks().map(|(item, _)| item))
-                }
+                // A held ship is an id; its stock is regenerated.
+                FrozenBodyType::PendingShip { .. } => None,
                 FrozenBodyType::Canister(canister) => unknown(&mut canister.item_ids()),
                 FrozenBodyType::WreckFragment(fragment) => unknown(&mut fragment.item_ids()),
                 FrozenBodyType::OreDrop(drop) => unknown(&mut drop.item_ids()),
diff --git a/crates/nova_world_base/src/save/tests.rs b/crates/nova_world_base/src/save/tests.rs
index 77c203618..00d276036 100644
--- a/crates/nova_world_base/src/save/tests.rs
+++ b/crates/nova_world_base/src/save/tests.rs
@@ -5,20 +5,20 @@ use std::{path::Path, time::Duration};
 use avian3d::prelude::{LinearVelocity, RigidBody, Rotation};
 use bevy::prelude::*;
 use nova_assets::prelude::{ContentCatalogDigest, LoadedSectionPack, LoadedSectionPacks};
-use nova_events::prelude::{EntityId, Meters, Meters3, MetersPerSecond3};
+use nova_events::prelude::EntityId;
 use nova_gameplay::prelude::{
-    nova_blast, resumed_lifetime, Allegiance, AssetRef, CargoCanister, CargoCanisterIdAllocator,
+    nova_blast, resumed_lifetime, AssetRef, CargoCanister, CargoCanisterIdAllocator,
     CargoCanisterRuntimeId, ClockFreeze, DamageMarks, DamageType, FreezeOwner, FrozenRoundFlight,
     GameItems, Health, IntegrityDestroyMarker, ItemCategoryType, ItemDesign, ItemDesignId,
     PlayerSpaceshipMarker, PointRotationOutput, ProjectileDamage, ProjectileOwner,
     RailgunSlugProjectileMarker, RoundRake, SavedBodyRef, SavedLifetime, SavedOwner,
-    SavedSectionRef, SavedTargetRef, SectionMarker, ShipCredits, ShipInventory, ShipInventoryStock,
+    SavedSectionRef, SavedTargetRef, SectionMarker, ShipCredits, ShipInventory,
     SpaceshipRootMarker, TorpedoProjectileMarker, TurretBulletProjectileMarker, ITEM_IRON_ORE,
 };
 use nova_scenario::prelude::{
     freeze_asteroid, freeze_ore_drop, freeze_ship, spaceship_scenario_object, AsteroidKind,
     AsteroidKindId, AsteroidMarker, AsteroidRadius, AsteroidSeed, AsteroidTexture, CurrentScenario,
-    MinedCanisterQueue, MinedOre, MinedOreDrop, ShipDesign, SpaceshipConfig,
+    MinedCanisterQueue, MinedOre, MinedOreDrop, SpaceshipConfig,
 };
 use nova_ship::prelude::{
     cargo_canister, freeze_canister, freeze_wreck_fragment, thaw_round, thaw_torpedo,
@@ -31,10 +31,7 @@ use nova_ship::prelude::{
     TorpedoTargetEntity, TorpedoTargetPosition, TorpedoWeave,
 };
 use nova_world::{
-    prelude::{
-        CivilizationId, CurrentSector, FrozenBodyType, FrozenSectors, SectorCoord, SectorShip,
-        SectorShipConditionType, SectorShipCrew, ShipRoleType, WorldConfig,
-    },
+    prelude::{CurrentSector, FrozenBodyType, FrozenSectors, SectorCoord, WorldConfig},
     SectorRoot,
 };
 
@@ -2055,42 +2052,6 @@ fn canister_ledger_with_item(coord: SectorCoord, id: u64, item: &ItemDesignId) -
     .unwrap()
 }
 
-/// A ledger cell at `coord` holding one ship `id` held back as a
-/// [`SectorShip`], whose spawn stock carries `item`, built through the
-/// record's RON form like [`ledger`].
-fn pending_ship_ledger(id: &str, coord: SectorCoord, item: &ItemDesignId) -> FrozenSectors {
-    let ship = SectorShip {
-        id: id.to_string(),
-        position: Meters3::ZERO,
-        rotation: Quat::IDENTITY,
-        initial_velocity: MetersPerSecond3::ZERO,
-        clearance: Meters(20.0),
-        design: ShipDesign::default(),
-        condition: SectorShipConditionType::Intact,
-        crew: Some(SectorShipCrew {
-            allegiance: Allegiance::Neutral,
-            patrol: Vec::new(),
-            stops: Vec::new(),
-            leash: Meters(5_000.0),
-        }),
-        civilization: CivilizationId {
-            world_seed: 0,
-            node: [0, 0, 0],
-        },
-        role: ShipRoleType::Civilian,
-        stock: ShipInventoryStock::new([(item.clone(), 1)]),
-        credits: 120,
-    };
-    let transform = ron::to_string(&Transform::IDENTITY).unwrap();
-    ron::from_str(&format!(
-        "{{{}: Visited([(id: Some({id:?}), name: None, transform: {transform}, \
-         visibility: None, motion: None, body: PendingShip({}))])}}",
-        ron::to_string(&coord).unwrap(),
-        ron::to_string(&ship).unwrap()
-    ))
-    .unwrap()
-}
-
 /// A ledger cell at `coord` holding `body` under `id`, built through the
 /// record's RON form like [`ledger`].
 fn ledger_holding(coord: SectorCoord, id: Option<&str>, body: &FrozenBodyType) -> FrozenSectors {
@@ -2278,10 +2239,6 @@ fn a_world_holding_an_item_the_catalog_lacks_is_refused_by_check_and_open() {
             canister_ledger_with_item(east, 5, &item),
             "the canister 5".to_string(),
         ),
-        (
-            pending_ship_ledger("pending_ship", east, &item),
-            "'pending_ship'".to_string(),
-        ),
     ];
     cases.extend(
         frozen_item_holders(&item)

From 0621b2dfab1c5e17ebf4dd0fc68ca0c68973d759 Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 11:17:58 +0300
Subject: [PATCH 06/22] Apply allegiance and lootable inside the spaceship
 spawn

Claim: three callers re-applied `SpaceshipConfig.allegiance` and
`lootable` beside `spaceship_scenario_object` (the scenario spawn action,
the sector spawn, `thaw_ship`), and the screenshot lineup hand-inserted
the same components, so a new caller could forget them.

Change: the bundle carries a `SpaceshipSpawnPolicy` component holding the
authored side and loot flag; `insert_spaceship_sections` applies the
allegiance override and `LootableShipMarker` and removes the policy, the
way it consumes `ShipInventoryStock`. A bevy bundle cannot hold an
optional component, and the controller marker's requirement default only
exists once that observer inserts the marker, so the observer is where the
override lands. The three re-applications and the lineup's extra
components are deleted. The sector spawn test now runs on an app with
`SpaceshipPlugin`, since the side and the marker land through the
observer; it reads the consumed stock back from `ShipInventory`.

Verification: cargo fmt; cargo check -p nova_world -p nova_world_base
-p nova_scenario -p nova_menu -p nova_interface --all-targets clean;
cargo check --examples --keep-going no errors; cargo test -p nova_world
--lib 39 passed; cargo test -p nova_world_base --lib 91 passed; cargo
test -p nova_scenario --lib spaceship 20 passed; cargo test -p
nova_scenario --lib allegiance 4 passed (the authored-override spawn
test still holds through the observer).

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_scenario/src/actions/spawn.rs     | 11 -----
 crates/nova_scenario/src/objects/spaceship.rs | 47 +++++++++++++++----
 crates/nova_world/src/streaming.rs            | 16 ++-----
 crates/nova_world/src/tests.rs                | 34 +++++++++-----
 .../screenshots/screenshot_generated_ships.rs |  2 -
 5 files changed, 63 insertions(+), 47 deletions(-)

diff --git a/crates/nova_scenario/src/actions/spawn.rs b/crates/nova_scenario/src/actions/spawn.rs
index f0edabf93..9c928005e 100644
--- a/crates/nova_scenario/src/actions/spawn.rs
+++ b/crates/nova_scenario/src/actions/spawn.rs
@@ -4,7 +4,6 @@
 use avian3d::prelude::*;
 use bevy::prelude::*;
 use nova_events::prelude::*;
-use nova_gameplay::prelude::LootableShipMarker;
 
 use crate::prelude::*;
 
@@ -317,16 +316,6 @@ fn spawn_scenario_spaceship(
         }
         None => {
             entity_commands.insert(spaceship_scenario_object(config.clone()));
-            // The authored allegiance override. Ordering is safe either way:
-            // the controller marker's requirement only fills an absent
-            // `Allegiance`, and a plain insert overwrites the requirement
-            // default - so the authored side always wins.
-            if let Some(allegiance) = config.allegiance {
-                entity_commands.insert(allegiance);
-            }
-            if config.lootable {
-                entity_commands.insert(LootableShipMarker);
-            }
         }
     }
     world.flush();
diff --git a/crates/nova_scenario/src/objects/spaceship.rs b/crates/nova_scenario/src/objects/spaceship.rs
index 3f821033b..f4602fced 100644
--- a/crates/nova_scenario/src/objects/spaceship.rs
+++ b/crates/nova_scenario/src/objects/spaceship.rs
@@ -33,7 +33,7 @@ pub mod prelude {
         freeze_ship, patrol_stops_fault, spaceship_scenario_object, thaw_ship, AIControllerConfig,
         FrozenShip, PlayerControllerConfig, ResumedSpaceship, SectionId, SectionSource,
         SpaceshipConfig, SpaceshipController, SpaceshipDesign, SpaceshipPlugin,
-        SpaceshipSectionConfig, SpaceshipSectionConfigPatch,
+        SpaceshipSectionConfig, SpaceshipSectionConfigPatch, SpaceshipSpawnPolicy,
     };
 }
 
@@ -448,6 +448,22 @@ pub struct SpaceshipSectionConfigPatch {
 #[derive(Component, Clone, Debug, Default, Deref, DerefMut, Reflect)]
 pub struct SpaceshipDesign(pub ShipDesignSource);
 
+/// The side and loot policy a ship was authored with, carried on the root
+/// from the spawn bundle to `insert_spaceship_sections`, which applies and
+/// removes it like [`ShipInventoryStock`].
+///
+/// A bundle cannot hold an optional component, and the controller marker's
+/// requirement default (Player ships `Allegiance::Player`, AI ships
+/// `Allegiance::Enemy`) only exists once the observer inserts that marker, so
+/// the observer is the one place the authored override can land after it.
+#[derive(Component, Clone, Debug, Default, Reflect)]
+pub struct SpaceshipSpawnPolicy {
+    /// [`SpaceshipConfig::allegiance`]: `Some` overrides the marker default.
+    pub allegiance: Option,
+    /// [`SpaceshipConfig::lootable`]: `true` inserts [`LootableShipMarker`].
+    pub lootable: bool,
+}
+
 /// The scenario/modding RON surface for a spaceship object: WHICH design it
 /// spawns, who drives it, which side it is on, and what it is permitted to do.
 /// Passed to `spaceship_scenario_object` to build the ship-root bundle.
@@ -508,9 +524,10 @@ pub struct SpaceshipConfig {
 }
 
 /// Build the ship-root bundle from a [`SpaceshipConfig`]: the marker, type
-/// name, controller, and the hull reference the `insert_spaceship_sections`
+/// name, controller, the hull reference the `insert_spaceship_sections`
 /// observer resolves to spawn the section children and wire the driver at
-/// spawn.
+/// spawn, and the [`SpaceshipSpawnPolicy`] that observer applies for the
+/// authored allegiance and the lootable marker. No caller re-applies either.
 ///
 /// The design's own components (collapse threshold, skin, style, the feedback
 /// voice) are inserted by that observer rather than here: a `Prototype` design
@@ -535,6 +552,10 @@ pub fn spaceship_scenario_object(config: SpaceshipConfig) -> impl Bundle {
         SpaceshipDesign(config.design),
         config.capabilities,
         config.inventory,
+        SpaceshipSpawnPolicy {
+            allegiance: config.allegiance,
+            lootable: config.lootable,
+        },
         ShipCredits(config.credits),
         RigidBody::Dynamic,
         LinearVelocity(config.initial_velocity.to_engine()),
@@ -790,12 +811,6 @@ pub fn thaw_ship(entity: &mut EntityCommands, frozen: FrozenShip) {
         }),
         ThawingShip(Some(state)),
     ));
-    if let Some(allegiance) = allegiance {
-        entity.insert(allegiance);
-    }
-    if lootable {
-        entity.insert(LootableShipMarker);
-    }
 }
 
 /// Spawns spaceship scenario objects: resolves each ship's hull and section
@@ -836,6 +851,7 @@ fn insert_spaceship_sections(
             &SpaceshipController,
             &Transform,
             &ShipInventoryStock,
+            &SpaceshipSpawnPolicy,
             Has,
         ),
         With,
@@ -845,7 +861,7 @@ fn insert_spaceship_sections(
     let entity = add.entity;
     trace!("insert_spaceship_sections: entity {:?}", entity);
 
-    let Ok((design_source, controller_config, transform, stock, derelict)) =
+    let Ok((design_source, controller_config, transform, stock, policy, derelict)) =
         q_spaceship.get(entity)
     else {
         // NOT an error: a root with no [`SpaceshipDesign`] is a hull somebody
@@ -911,6 +927,17 @@ fn insert_spaceship_sections(
     ));
     commands.entity(entity).remove::();
 
+    // Order against the controller marker below does not matter: the
+    // marker's requirement only fills an absent `Allegiance`, and a plain
+    // insert overwrites the requirement default, so the authored side wins.
+    if let Some(allegiance) = policy.allegiance {
+        commands.entity(entity).insert(allegiance);
+    }
+    if policy.lootable {
+        commands.entity(entity).insert(LootableShipMarker);
+    }
+    commands.entity(entity).remove::();
+
     // An AI ship with no weapon section cannot fight; it becomes a
     // non-combatant below so it flies its routine and never chases. Counted
     // over the sections this spawn builds, so a thawed ship whose last weapon
diff --git a/crates/nova_world/src/streaming.rs b/crates/nova_world/src/streaming.rs
index 49e0d934f..03ba9269b 100644
--- a/crates/nova_world/src/streaming.rs
+++ b/crates/nova_world/src/streaming.rs
@@ -23,8 +23,7 @@ use bevy::{
 use nova_assets::prelude::ASTEROID_TEXTURE_PATH;
 use nova_events::prelude::{Meters, Meters3, ScenarioAddressableMarker};
 use nova_gameplay::prelude::{
-    Allegiance, AssetRef, DerelictShipMarker, IntegrityEnvelope, LootableShipMarker, TempEntity,
-    UnsettledBody,
+    Allegiance, AssetRef, DerelictShipMarker, IntegrityEnvelope, TempEntity, UnsettledBody,
 };
 use nova_scenario::prelude::{
     asteroid_scenario_object_prepared, base_scenario_object, planet_scenario_object_prepared,
@@ -344,13 +343,9 @@ const SECTOR_ENEMY_ENGAGE_DELAY: f32 = 8.0;
 /// so a cell that streams in around the player does not open fire on the
 /// spawn frame. A derelict has nobody aboard and nobody's side.
 ///
-/// The allegiance is inserted beside the bundle for the same reason the
-/// scenario loader does it - the controller marker's requirement default would
-/// otherwise decide. A derelict also carries [`DerelictShipMarker`] in the
-/// same bundle, so the section spawn sees it and every section but hull and
-/// docking port spawns inactive, and [`LootableShipMarker`], so a ship docked
-/// to it may Take its stock. The scenario spawn action inserts that marker
-/// from `lootable`; this spawn does not run through it.
+/// A derelict carries [`DerelictShipMarker`] in the same bundle as the root,
+/// so the section spawn sees it and every section but hull and docking port
+/// spawns inactive; its `lootable` config lets a docked ship Take its stock.
 fn spawn_sector_ship(commands: &mut Commands, root: Entity, ship: SectorShip) {
     let SectorShip {
         id,
@@ -409,11 +404,10 @@ fn spawn_sector_ship(commands: &mut Commands, root: Entity, ship: SectorShip) {
             credits,
             ..default()
         }),
-        allegiance,
         ChildOf(root),
     );
     if derelict {
-        commands.spawn((ship, DerelictShipMarker, LootableShipMarker));
+        commands.spawn((ship, DerelictShipMarker));
     } else {
         commands.spawn(ship);
     }
diff --git a/crates/nova_world/src/tests.rs b/crates/nova_world/src/tests.rs
index 8b1d35c68..8c64b9987 100644
--- a/crates/nova_world/src/tests.rs
+++ b/crates/nova_world/src/tests.rs
@@ -19,14 +19,18 @@
 use avian3d::prelude::{LinearVelocity, RigidBody};
 use bevy::{ecs::system::RunSystemOnce, prelude::*};
 use nova_events::prelude::{Meters, Meters3, MetersPerSecond3};
-use nova_gameplay::prelude::{
-    Allegiance, AssetRef, DerelictShipMarker, GravityAffected, GravityWell, IntegrityEnvelope,
-    ItemDesignId, LootableShipMarker, ShipCredits, ShipInventoryStock, ITEM_HULL_PLATE,
+use nova_gameplay::{
+    prelude::{
+        Allegiance, AssetRef, DerelictShipMarker, GravityAffected, GravityWell, IntegrityEnvelope,
+        ItemDesignId, LootableShipMarker, ShipCredits, ShipInventory, ShipInventoryStock,
+        ITEM_HULL_PLATE,
+    },
+    test_support::test_items,
 };
 use nova_scenario::prelude::{
     AIControllerConfig, AsteroidMarker, AsteroidPlugin, PlanetConfig, PlanetType, SectionSource,
-    ShipDesign, SpaceshipController, SpaceshipSectionConfig, ASTEROID_GEOMETRIC_FACTOR_MAX,
-    KIND_ROCK,
+    ShipDesign, SpaceshipController, SpaceshipPlugin, SpaceshipSectionConfig,
+    ASTEROID_GEOMETRIC_FACTOR_MAX, KIND_ROCK,
 };
 use nova_ship::prelude::{
     BaseSectionConfig, GameSections, HullSectionConfig, SectionConfig, SectionKind,
@@ -913,13 +917,18 @@ fn intact_and_derelict(input: SectorGenerationInput) -> SectorManifest {
 /// Take it, and flown by nobody on nobody's side; an intact ship spawns
 /// neither lootable nor stocked, flown by an AI on its crew's side along its
 /// crew's loop, an Enemy crew holding fire for its arrival grace. Each spawns
-/// with its manifest credits.
+/// with its manifest credits. Through the real ship spawn: the side and the
+/// loot marker land when the section observer applies the spawn policy.
 #[test]
 fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_by_its_crew() {
     let config = answering(intact_and_derelict);
     let prepared =
         prepare_sector(config.clone(), SectorCoord::ORIGIN).expect("two valid ships must prepare");
-    let mut world = World::new();
+    let mut app = App::new();
+    app.add_plugins((MinimalPlugins, SpaceshipPlugin));
+    app.insert_resource(test_sections());
+    app.insert_resource(test_items());
+    let world = app.world_mut();
     materialize_sector(
         &mut world.commands(),
         prepared,
@@ -934,17 +943,16 @@ fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_b
             &Name,
             Has,
             Has,
-            &ShipInventoryStock,
+            &ShipInventory,
             &ShipCredits,
         )>()
-        .iter(&world)
-        .map(|(name, derelict, lootable, stock, credits)| {
+        .iter(world)
+        .map(|(name, derelict, lootable, hold, credits)| {
             (
                 name.to_string(),
                 derelict,
                 lootable,
-                stock
-                    .stacks()
+                hold.stacks()
                     .map(|(item, count)| (item.clone(), count))
                     .collect(),
                 credits.0,
@@ -968,7 +976,7 @@ fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_b
 
     let mut flown: Vec<(String, Allegiance, Option)> = world
         .query::<(&Name, &Allegiance, &SpaceshipController)>()
-        .iter(&world)
+        .iter(world)
         .map(|(name, allegiance, controller)| {
             let crew = match controller {
                 SpaceshipController::AI(config) => Some(config.clone()),
diff --git a/examples/screenshots/screenshot_generated_ships.rs b/examples/screenshots/screenshot_generated_ships.rs
index 3f738bfa9..8853c8336 100644
--- a/examples/screenshots/screenshot_generated_ships.rs
+++ b/examples/screenshots/screenshot_generated_ships.rs
@@ -278,9 +278,7 @@ fn stand_the_wreck(_: On, mut commands: Commands, stage: Res
Date: Sun, 11 Oct 2026 11:32:23 +0300
Subject: [PATCH 07/22] Carry whole spawn configs in sector records

Claim: `materialize_sector` invented spawn policy the generator never
stated: it built each rock's `AsteroidConfig` around a texture it owned,
turned a `SectorShipCrew` into an `AIControllerConfig` with its own
engage delay, and decided that a derelict is unflown, Neutral and
lootable. Two generators (base game, example fixture) and every consumer
had to agree with it by convention.

Change: `SectorAsteroid { id, position, config: AsteroidConfig }` and
`SectorShip { id, position, rotation, clearance, condition, civilization,
role, spawn: SpaceshipConfig }`. `SectorShipCrew` and `check_ship_crew`
are deleted. `validate_manifest` refuses a rock with `seed: None` or a
`AssetRef::Handle` texture, a ship with `allegiance: None`, a catalog
(non-Inline) design, a player controller, an AI on a derelict, and an AI
route the controller could not fly, through the `patrol_stops_fault` the
scenario lint and the spawn already share. `plan_ship` decides the
derelict policy and the Enemy arrival grace (`ENEMY_ENGAGE_DELAY` moves
to `nova_world_base::sector_ships`), and the base generator and the
example fixtures name `ASTEROID_TEXTURE_PATH` themselves; `nova_world`
drops its `nova_assets` dependency and `materialize_sector` its texture
parameter. `SectorShip::design()` and `SectorAsteroid::seed()` read the
fields the trusted description guarantees. `canonical()` prints the new
fields (side, loot flag, capabilities, controller apart from its route,
texture, destroy sound, lock signature) and so changes the pinned
window digest in `nova_world_base` tests: the bodies are the same, the
text moved, and the test doc now says to state which of the two moved.
Records derive `Clone, Debug` uniformly; `PartialEq` is dropped from
`SectorAsteroid` because the scenario configs do not implement it and
`canonical()` is the record's equality. The nova_world_base window test
now proves the crew policy where it lives (leash, Enemy grace, unflown
Neutral lootable wreck).

Verification: cargo fmt; cargo check -p nova_world -p nova_world_base
-p nova_scenario -p nova_menu -p nova_interface --all-targets clean;
cargo check --examples --keep-going clean; cargo test -p nova_world
--lib 39 passed (23 refusal cases, canonical identity 15 variants);
cargo test -p nova_world_base --lib 91 passed; cargo test -p
nova_scenario --lib spaceship 20 passed.

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 Cargo.lock                                    |   1 -
 crates/nova_interface/src/inventory/tests.rs  |  18 +-
 crates/nova_world/Cargo.toml                  |   2 -
 crates/nova_world/src/generation.rs           | 306 +++++++++------
 crates/nova_world/src/lib.rs                  |   8 +-
 crates/nova_world/src/streaming.rs            | 108 +-----
 crates/nova_world/src/tests.rs                | 362 ++++++++++++------
 crates/nova_world/src/tests/frozen.rs         |  33 +-
 crates/nova_world_base/src/clusters.rs        |  81 ++--
 crates/nova_world_base/src/sector_ships.rs    |  67 ++--
 crates/nova_world_base/src/tests.rs           |  68 +++-
 examples/playable/world_features.rs           |   2 +-
 examples/playable/world_sector_inspector.rs   |   6 +-
 examples/screenshots/loop_world_start.rs      |   2 +-
 examples/shared/world_fixture/clustered.rs    |  19 +-
 .../shared/world_fixture/uniform_asteroids.rs |  15 +-
 examples/systems/system_world_sectors.rs      |   2 +-
 17 files changed, 643 insertions(+), 457 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index dabd9709e..0e642b7a1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4994,7 +4994,6 @@ version = "0.15.0"
 dependencies = [
  "avian3d",
  "bevy",
- "nova_assets",
  "nova_events",
  "nova_gameplay",
  "nova_scenario",
diff --git a/crates/nova_interface/src/inventory/tests.rs b/crates/nova_interface/src/inventory/tests.rs
index 5d3f6c251..fac5ff3aa 100644
--- a/crates/nova_interface/src/inventory/tests.rs
+++ b/crates/nova_interface/src/inventory/tests.rs
@@ -1576,7 +1576,7 @@ mod generated_wreck {
         flight::prelude::NovaFlightPlugin,
         prelude::{
             DockingConnectionRequest, GameSections, PDControllerPlugin, SectionConfig,
-            SpaceshipSectionPlugin,
+            ShipCapabilities, SpaceshipSectionPlugin,
         },
     };
     use nova_world::{materialize_sector, prelude::*, ObserverBody};
@@ -1740,15 +1740,20 @@ mod generated_wreck {
                 id: sector_id(SectorCoord::ORIGIN, "ship", 0),
                 position: centre,
                 rotation: Quat::IDENTITY,
-                initial_velocity: MetersPerSecond3::ZERO,
                 clearance: wreck.clearance,
-                design: wreck.design.clone(),
                 condition: SectorShipConditionType::Derelict,
-                crew: None,
                 civilization: request.civilization,
                 role: request.role,
-                stock,
-                credits: 0,
+                spawn: SpaceshipConfig {
+                    design: ShipDesignSource::Inline(wreck.design.clone()),
+                    controller: SpaceshipController::None,
+                    initial_velocity: MetersPerSecond3::ZERO,
+                    allegiance: Some(Allegiance::Neutral),
+                    capabilities: ShipCapabilities::default(),
+                    inventory: stock,
+                    lootable: true,
+                    credits: 0,
+                },
             }),
         };
         let prepared =
@@ -1777,7 +1782,6 @@ mod generated_wreck {
         materialize_sector(
             &mut world.commands(),
             prepared,
-            &AssetRef::default(),
             &loaded,
             ObserverBody {
                 position: centre + Meters3::new(0.0, 0.0, 10_000.0),
diff --git a/crates/nova_world/Cargo.toml b/crates/nova_world/Cargo.toml
index 98b2a55cf..033841e18 100644
--- a/crates/nova_world/Cargo.toml
+++ b/crates/nova_world/Cargo.toml
@@ -13,8 +13,6 @@ avian3d = { version = "0.7.0" }
 # Components, resources, system sets and `AsyncComputeTaskPool`. This crate
 # owns systems, so it builds a plugin; `nova_world_base` adds it for the game.
 bevy = { version = "0.19.0" }
-# `ASTEROID_TEXTURE_PATH`: the texture a materialized sector hands its rocks.
-nova_assets = { path = "../nova_assets" }
 # `Meters` and `Meters3`: every world quantity here is authored in meters and
 # converted at the engine edge.
 nova_events = { path = "../nova_events" }
diff --git a/crates/nova_world/src/generation.rs b/crates/nova_world/src/generation.rs
index c1e59e47b..7f00d518d 100644
--- a/crates/nova_world/src/generation.rs
+++ b/crates/nova_world/src/generation.rs
@@ -11,11 +11,13 @@
 use std::{collections::BTreeSet, fmt, sync::Arc};
 
 use bevy::{log::info_span, math::Quat};
-use nova_events::prelude::{Meters, Meters3, MetersPerSecond3};
-use nova_gameplay::prelude::{Allegiance, Fnv32, SeedStream, ShipInventoryStock};
+use nova_events::prelude::{Meters, Meters3};
+use nova_gameplay::prelude::{AssetRef, Fnv32, SeedStream};
 use nova_scenario::prelude::{
-    is_asteroid_kind, prepare_asteroid_geometry, prepare_planet, AsteroidKindId, PlanetConfig,
-    PreparedAsteroid, PreparedPlanet, SectionSource, ShipDesign, ASTEROID_GEOMETRIC_FACTOR_MAX,
+    is_asteroid_kind, patrol_stops_fault, prepare_asteroid_geometry, prepare_planet,
+    AIControllerConfig, AsteroidConfig, PlanetConfig, PreparedAsteroid, PreparedPlanet,
+    SectionSource, ShipDesign, ShipDesignSource, SpaceshipConfig, SpaceshipController,
+    ASTEROID_GEOMETRIC_FACTOR_MAX,
 };
 
 use crate::{
@@ -24,21 +26,34 @@ use crate::{
 };
 
 /// One generated rock, in world meters.
-#[derive(Clone, Debug, PartialEq)]
+#[derive(Clone, Debug)]
 pub struct SectorAsteroid {
     /// The rock's scenario id, prefixed with its owning cell's slug.
     pub id: String,
     /// Where it stands, in meters from the world origin.
     pub position: Meters3,
-    /// Its nominal radius.
-    pub radius: Meters,
-    /// Its asteroid kind id, drawn from the generator's table.
-    pub kind: AsteroidKindId,
-    /// Its silhouette seed.
-    pub seed: u32,
-    /// Explicit initial motion in meters per second. Zero is an intentional
-    /// coasting start and does not disable gravity.
-    pub initial_velocity: MetersPerSecond3,
+    /// The rock as the spawn builds it. `seed` is `Some`: the generator pins
+    /// the silhouette, the spawn never derives one. `texture` is a path: a
+    /// saved cell names it on disk. [`validate_manifest`] refuses both
+    /// otherwise.
+    pub config: AsteroidConfig,
+}
+
+impl SectorAsteroid {
+    /// The silhouette seed the generator pinned.
+    ///
+    /// # Panics
+    ///
+    /// On an entry with none. [`validate_manifest`] refuses one, so a
+    /// description never panics here.
+    pub fn seed(&self) -> u32 {
+        self.config.seed.unwrap_or_else(|| {
+            panic!(
+                "nova_world: rock '{}' pins no silhouette seed; validate_manifest refuses one",
+                self.id
+            )
+        })
+    }
 }
 
 /// One generated planetoid: a real [`PlanetConfig`], not a big rock.
@@ -196,21 +211,8 @@ impl ShipRoleType {
     }
 }
 
-/// Who flies an intact generated ship, and where.
-#[derive(Clone, Debug)]
-pub struct SectorShipCrew {
-    /// The side it fights for: its civilization's side.
-    pub allegiance: Allegiance,
-    /// The waypoint loop it patrols, in meters from the world origin. Empty
-    /// when no loop fit its cell: the ship then holds its spawn point.
-    pub patrol: Vec,
-    /// Seconds it holds at each waypoint, one per `patrol` entry.
-    pub stops: Vec,
-    /// How far from its patrol centre, or its spawn point, it may chase.
-    pub leash: Meters,
-}
-
-/// One generated ship: a hull, its civilization, and its crew if it works.
+/// One generated ship: where a hull stands, what it is, and the spawn that
+/// builds it.
 #[derive(Clone, Debug)]
 pub struct SectorShip {
     /// The ship's scenario id, prefixed with its owning cell's slug.
@@ -219,28 +221,42 @@ pub struct SectorShip {
     pub position: Meters3,
     /// Its full orientation: a finite unit quaternion.
     pub rotation: Quat,
-    /// Explicit initial motion in meters per second.
-    pub initial_velocity: MetersPerSecond3,
     /// The radius around the root that holds the whole hull in any
     /// orientation. At most [`SECTOR_SHIP_CLEARANCE_MAX`].
     pub clearance: Meters,
-    /// The generated design, carried inline: every section names a section
-    /// prototype of the loaded catalog.
-    pub design: ShipDesign,
-    /// Whether it works or is a derelict.
+    /// Whether it works or is a derelict. A derelict spawns with every
+    /// section but hull and docking port inactive.
     pub condition: SectorShipConditionType,
-    /// Who flies it: `Some` exactly when it is intact. A derelict has nobody
-    /// aboard and nobody's side.
-    pub crew: Option,
     /// The civilization it belongs to, or belonged to as a derelict.
     pub civilization: CivilizationId,
     /// What it was built for. A derelict keeps its former role.
     pub role: ShipRoleType,
-    /// What its hold carries when it spawns. The spawn refuses stock past
-    /// the hold its resolved design gives it.
-    pub stock: ShipInventoryStock,
-    /// Its credit balance when it spawns.
-    pub credits: u32,
+    /// The ship as the spawn builds it, whole: the generator states the
+    /// design (inline, every section a prototype of the loaded catalog), who
+    /// flies it, its side (`Some`), its motion, its stock, whether it may be
+    /// looted and its balance. Materialization adds nothing. A derelict is
+    /// flown by nobody; [`validate_manifest`] refuses an AI on one, a player
+    /// on any, a catalog design and a `None` side.
+    pub spawn: SpaceshipConfig,
+}
+
+impl SectorShip {
+    /// The inline design.
+    ///
+    /// # Panics
+    ///
+    /// On a design that names a catalog prototype. [`validate_manifest`]
+    /// refuses one, so a description never panics here.
+    pub fn design(&self) -> &ShipDesign {
+        match &self.spawn.design {
+            ShipDesignSource::Inline(design) => design,
+            ShipDesignSource::Prototype { id, .. } => panic!(
+                "nova_world: ship '{}' names the catalog design '{id}'; validate_manifest refuses \
+                 one",
+                self.id
+            ),
+        }
+    }
 }
 
 /// What a [`SectorGenerator`] says one cell holds. UNTRUSTED.
@@ -323,15 +339,16 @@ impl SectorDescription {
     /// values, not placement: they are printed exact, as the shortest text
     /// that reads back to the same f32, and `None` prints apart from every
     /// `Some`. Rounding them would call two different worlds the same one.
-    /// A ship's stock prints every stack and count, and its credits their
-    /// balance. Its crew prints its side, leash, and each waypoint and stop,
-    /// or `none`. Its civilization, role,
-    /// design integrity, design presentation and each section's prototype
-    /// patch print through `Debug`: field by field in declaration order, a
-    /// float as the shortest text that reads back to it, a non-finite float
-    /// as `NaN` or `inf`, apart from `None`. An asset handle prints its
-    /// runtime id, so a design that carries one compares equal only within
-    /// one run; generated designs carry none.
+    /// A rock's texture, destroy sound and lock signature, a ship's stock,
+    /// side, capabilities, loot flag, civilization, role, design integrity,
+    /// design presentation, each section's prototype patch and its controller
+    /// apart from its route print through `Debug`: field by field in
+    /// declaration order, a float as the shortest text that reads back to it,
+    /// a non-finite float as `NaN` or `inf`, apart from `None`. A ship's
+    /// credits print their balance; an AI's route prints each waypoint as
+    /// placement with its stop. An asset handle prints its runtime id, so a
+    /// config that carries one compares equal only within one run; generated
+    /// configs carry none.
     pub fn canonical(&self) -> String {
         let point = |position: Meters3| {
             let p = position.get();
@@ -339,14 +356,19 @@ impl SectorDescription {
         };
         let mut out = format!("{}\n", self.coord);
         for body in &self.asteroids {
+            let config = &body.config;
             out.push_str(&format!(
-                "asteroid {} {} r{:.2} {} s{} velocity {:?}\n",
+                "asteroid {} {} r{:.2} {} seed {:?} velocity {:?} texture {:?} destroy_sound {:?} \
+                 lock_signature {:?}\n",
                 body.id,
                 point(body.position),
-                body.radius.get(),
-                body.kind,
-                body.seed,
-                body.initial_velocity.get()
+                config.radius.get(),
+                config.kind,
+                config.seed,
+                config.initial_velocity.get(),
+                config.texture,
+                config.destroy_sound,
+                config.lock_signature.map(Meters::get),
             ));
         }
         for planet in &self.planets {
@@ -365,36 +387,46 @@ impl SectorDescription {
             ));
         }
         for ship in &self.ships {
+            let spawn = &ship.spawn;
+            let design = ship.design();
             out.push_str(&format!(
-                "ship {} {} {} velocity {:?} c{:.2} {} {} {} stock {:?} credits {} integrity {:?} \
-                 presentation {:?}\n",
+                "ship {} {} {} velocity {:?} c{:.2} {} {} {} stock {:?} credits {} side {:?} \
+                 lootable {} capabilities {:?} integrity {:?} presentation {:?}\n",
                 ship.id,
                 point(ship.position),
                 canonical_rotation(ship.rotation),
-                ship.initial_velocity.get(),
+                spawn.initial_velocity.get(),
                 ship.clearance.get(),
                 ship.condition.label(),
                 ship.civilization,
                 ship.role.label(),
-                ship.stock.stacks().collect::>(),
-                ship.credits,
-                ship.design.integrity,
-                ship.design.presentation,
+                spawn.inventory.stacks().collect::>(),
+                spawn.credits,
+                spawn.allegiance,
+                spawn.lootable,
+                spawn.capabilities,
+                design.integrity,
+                design.presentation,
             ));
-            match &ship.crew {
-                Some(crew) => {
-                    out.push_str(&format!(
-                        "  crew {:?} leash {:.2}\n",
-                        crew.allegiance,
-                        crew.leash.get()
-                    ));
-                    for (waypoint, stop) in crew.patrol.iter().zip(&crew.stops) {
+            match &spawn.controller {
+                SpaceshipController::None => out.push_str("  controller none\n"),
+                SpaceshipController::Player(config) => {
+                    out.push_str(&format!("  controller player {config:?}\n"));
+                }
+                SpaceshipController::AI(config) => {
+                    let routine = AIControllerConfig {
+                        patrol: Vec::new(),
+                        patrol_stops: Vec::new(),
+                        ..config.clone()
+                    };
+                    out.push_str(&format!("  controller ai {routine:?}\n"));
+                    for (index, waypoint) in config.patrol.iter().enumerate() {
+                        let stop = config.patrol_stops.get(index);
                         out.push_str(&format!("  waypoint {} stop {stop:?}\n", point(*waypoint)));
                     }
                 }
-                None => out.push_str("  crew none\n"),
             }
-            for section in &ship.design.sections {
+            for section in &design.sections {
                 let p = section.position;
                 out.push_str(&format!(
                     "  section {} {} {:.3} {:.3} {:.3} {} patch {:?}\n",
@@ -563,10 +595,12 @@ pub fn sector_id(coord: SectorCoord, name: &str, index: usize) -> String {
 /// centre has no finite position in meters;
 /// [`SectorFault::Manifest`] for the wrong cell, an object outside its cell or
 /// overlapping another, an id another cell owns, a non-finite initial
-/// velocity, a planet config
-/// [`PlanetConfig::validate`] refuses, a ship clearance above the maximum, or
-/// a ship design with no sections, a repeated section id, an inline section or
-/// a non-finite section pose;
+/// velocity, a rock with no silhouette seed or a texture that is a runtime
+/// handle, a planet config [`PlanetConfig::validate`] refuses, a ship
+/// clearance above the maximum, a ship design that names a catalog prototype,
+/// has no sections, a repeated section id, an inline section or a non-finite
+/// section pose, a ship with no side, flown by a player, a derelict flown by
+/// an AI, or an AI route the controller could not fly;
 /// [`SectorFault::InvalidGeometry`] for non-finite geometry, a ship rotation
 /// that is not a unit quaternion and a ship clearance that is not positive;
 /// [`SectorFault::DuplicateId`] and [`SectorFault::UnknownKind`].
@@ -618,24 +652,39 @@ pub fn validate_manifest(
 
     for body in &manifest.asteroids {
         own(&body.id)?;
-        if !body.radius.get().is_finite() || body.radius.get() <= 0.0 {
+        let config = &body.config;
+        if !config.radius.get().is_finite() || config.radius.get() <= 0.0 {
             return Err(SectorFault::InvalidGeometry {
                 id: body.id.clone(),
             });
         }
-        if !is_asteroid_kind(&body.kind) {
+        if !is_asteroid_kind(&config.kind) {
             return Err(SectorFault::UnknownKind {
-                kind: body.kind.clone(),
+                kind: config.kind.clone(),
             });
         }
-        if !body.initial_velocity.is_finite() {
+        if !config.initial_velocity.is_finite() {
             return Err(refuse(
                 &body.id,
                 "initial_velocity",
-                format!("{:?}", body.initial_velocity.get()),
+                format!("{:?}", config.initial_velocity.get()),
             ));
         }
-        let clearance = Meters(body.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX);
+        if config.seed.is_none() {
+            return Err(refuse(
+                &body.id,
+                "seed",
+                "none; a generated rock pins its silhouette".to_string(),
+            ));
+        }
+        if let AssetRef::Handle(handle) = &config.texture {
+            return Err(refuse(
+                &body.id,
+                "texture",
+                format!("the runtime handle {handle:?}, not an asset path"),
+            ));
+        }
+        let clearance = Meters(config.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX);
         stand_inside(input, &mut standing, &body.id, body.position, clearance)?;
     }
     for planet in &manifest.planets {
@@ -654,11 +703,12 @@ pub fn validate_manifest(
     }
     for ship in &manifest.ships {
         own(&ship.id)?;
-        if !ship.initial_velocity.is_finite() {
+        let spawn = &ship.spawn;
+        if !spawn.initial_velocity.is_finite() {
             return Err(refuse(
                 &ship.id,
                 "initial_velocity",
-                format!("{:?}", ship.initial_velocity.get()),
+                format!("{:?}", spawn.initial_velocity.get()),
             ));
         }
         if !ship.rotation.is_finite()
@@ -684,8 +734,26 @@ pub fn validate_manifest(
                 ),
             ));
         }
-        check_ship_design(ship).map_err(|value| refuse(&ship.id, "design", value))?;
-        check_ship_crew(ship).map_err(|value| refuse(&ship.id, "crew", value))?;
+        let design = match &spawn.design {
+            ShipDesignSource::Inline(design) => design,
+            ShipDesignSource::Prototype { id, .. } => {
+                return Err(refuse(
+                    &ship.id,
+                    "design",
+                    format!("the catalog design '{id}', not an inline design"),
+                ));
+            }
+        };
+        check_ship_design(design).map_err(|value| refuse(&ship.id, "design", value))?;
+        if spawn.allegiance.is_none() {
+            return Err(refuse(
+                &ship.id,
+                "allegiance",
+                "none; a generated ship states its side".to_string(),
+            ));
+        }
+        check_ship_controller(ship.condition, &spawn.controller)
+            .map_err(|value| refuse(&ship.id, "controller", value))?;
         stand_inside(
             input,
             &mut standing,
@@ -708,42 +776,42 @@ pub fn validate_manifest(
     })
 }
 
-/// Refuse a crew that does not match the condition, or one the AI could not
-/// fly: a waypoint that is not finite, a stop count that differs from the
-/// waypoint count, a stop that is not a finite positive duration, or a leash
-/// that is not a finite positive distance.
-fn check_ship_crew(ship: &SectorShip) -> Result<(), String> {
-    let crew = match (ship.condition, &ship.crew) {
-        (SectorShipConditionType::Intact, Some(crew)) => crew,
-        (SectorShipConditionType::Derelict, None) => return Ok(()),
-        (SectorShipConditionType::Intact, None) => {
-            return Err("missing on an intact ship".to_string());
+/// Refuse a controller the streamed world cannot host: a player (the
+/// scenario spawns the one player), an AI on a derelict (its systems spawn
+/// inactive), or an AI route the controller could not fly, by the same
+/// [`patrol_stops_fault`] the scenario lint and the spawn apply, plus a
+/// waypoint that is not finite, a leash or an engage delay that is not
+/// finite, or a leash that is not positive.
+fn check_ship_controller(
+    condition: SectorShipConditionType,
+    controller: &SpaceshipController,
+) -> Result<(), String> {
+    let config = match (condition, controller) {
+        (_, SpaceshipController::Player(_)) => {
+            return Err("a player; the scenario spawns the player".to_string());
         }
-        (SectorShipConditionType::Derelict, Some(_)) => {
-            return Err("present on a derelict".to_string());
+        (_, SpaceshipController::None) => return Ok(()),
+        (SectorShipConditionType::Derelict, SpaceshipController::AI(_)) => {
+            return Err("an AI on a derelict".to_string());
         }
+        (SectorShipConditionType::Intact, SpaceshipController::AI(config)) => config,
     };
-    if !crew.patrol.iter().copied().all(position_is_finite) {
+    if !config.patrol.iter().copied().all(position_is_finite) {
         return Err("a patrol waypoint is not finite".to_string());
     }
-    if crew.stops.len() != crew.patrol.len() {
-        return Err(format!(
-            "{} stop(s) for {} waypoint(s)",
-            crew.stops.len(),
-            crew.patrol.len()
-        ));
+    if let Some(fault) = patrol_stops_fault(config) {
+        return Err(fault);
     }
-    if let Some(stop) = crew
-        .stops
-        .iter()
-        .find(|stop| !stop.is_finite() || **stop <= 0.0)
+    if let Some(leash) = config
+        .leash
+        .map(Meters::get)
+        .filter(|leash| !leash.is_finite() || *leash <= 0.0)
     {
-        return Err(format!("stop {stop} s is not a finite positive duration"));
-    }
-    let leash = crew.leash.get();
-    if !leash.is_finite() || leash <= 0.0 {
         return Err(format!("leash {leash} m is not a finite positive distance"));
     }
+    if let Some(delay) = config.engage_delay.filter(|delay| !delay.is_finite()) {
+        return Err(format!("engage delay {delay} s is not finite"));
+    }
     Ok(())
 }
 
@@ -751,12 +819,12 @@ fn check_ship_crew(ship: &SectorShip) -> Result<(), String> {
 /// sections, a section id used twice, an inline section rather than a
 /// prototype reference, or a section pose that is not finite. Whether each
 /// prototype resolves is main-thread work in `materialize_sector`.
-fn check_ship_design(ship: &SectorShip) -> Result<(), String> {
-    if ship.design.sections.is_empty() {
+fn check_ship_design(design: &ShipDesign) -> Result<(), String> {
+    if design.sections.is_empty() {
         return Err("no sections".to_string());
     }
     let mut ids = BTreeSet::new();
-    for section in &ship.design.sections {
+    for section in &design.sections {
         if !ids.insert(section.id.as_str()) {
             return Err(format!("section id '{}' used twice", section.id));
         }
@@ -934,7 +1002,7 @@ pub(crate) fn prepare_cell(
                 asteroids
                     .into_iter()
                     .map(|body| PreparedRock {
-                        geometry: prepare_asteroid_geometry(body.seed, body.radius),
+                        geometry: prepare_asteroid_geometry(body.seed(), body.config.radius),
                         body,
                     })
                     .collect()
diff --git a/crates/nova_world/src/lib.rs b/crates/nova_world/src/lib.rs
index bdd77f232..ac8496185 100644
--- a/crates/nova_world/src/lib.rs
+++ b/crates/nova_world/src/lib.rs
@@ -192,8 +192,7 @@ pub use crate::{
     generation::{
         bodies_clear, generate_sector, prepare_sector, sector_id, validate_manifest,
         CivilizationId, PreparedSector, SectorAsteroid, SectorDescription, SectorManifest,
-        SectorPlanet, SectorShip, SectorShipConditionType, SectorShipCrew, ShipRoleType,
-        SECTOR_SHIP_CLEARANCE_MAX,
+        SectorPlanet, SectorShip, SectorShipConditionType, ShipRoleType, SECTOR_SHIP_CLEARANCE_MAX,
     },
     streaming::{
         clear_sector_work, collect_sector_jobs, desired_sectors, live_sectors,
@@ -211,9 +210,8 @@ pub mod prelude {
         bodies_clear, generate_sector, prepare_sector, sector_id, validate_manifest,
         CivilizationId, NovaWorldPlugin, NovaWorldSystems, PreparedSector, SectorAsteroid,
         SectorCoord, SectorDescription, SectorFault, SectorGenerationInput, SectorGenerator,
-        SectorManifest, SectorPlanet, SectorShip, SectorShipConditionType, SectorShipCrew,
-        ShipRoleType, WorldConfig, WorldGeometry, ACTIVE_WINDOW_SECTORS_MAX,
-        SECTOR_SHIP_CLEARANCE_MAX,
+        SectorManifest, SectorPlanet, SectorShip, SectorShipConditionType, ShipRoleType,
+        WorldConfig, WorldGeometry, ACTIVE_WINDOW_SECTORS_MAX, SECTOR_SHIP_CLEARANCE_MAX,
     };
     pub use crate::{
         frozen::{
diff --git a/crates/nova_world/src/streaming.rs b/crates/nova_world/src/streaming.rs
index 03ba9269b..1b3ae897a 100644
--- a/crates/nova_world/src/streaming.rs
+++ b/crates/nova_world/src/streaming.rs
@@ -20,16 +20,12 @@ use bevy::{
     prelude::*,
     tasks::{block_on, poll_once, AsyncComputeTaskPool, Task},
 };
-use nova_assets::prelude::ASTEROID_TEXTURE_PATH;
 use nova_events::prelude::{Meters, Meters3, ScenarioAddressableMarker};
-use nova_gameplay::prelude::{
-    Allegiance, AssetRef, DerelictShipMarker, IntegrityEnvelope, TempEntity, UnsettledBody,
-};
+use nova_gameplay::prelude::{DerelictShipMarker, IntegrityEnvelope, TempEntity, UnsettledBody};
 use nova_scenario::prelude::{
     asteroid_scenario_object_prepared, base_scenario_object, planet_scenario_object_prepared,
-    resolve_ship_design, spaceship_scenario_object, AIControllerConfig, AsteroidConfig,
-    BaseScenarioObjectConfig, GameShipDesigns, PlanetMarker, ShipDesignSource, SpaceshipConfig,
-    SpaceshipController,
+    resolve_ship_design, spaceship_scenario_object, BaseScenarioObjectConfig, GameShipDesigns,
+    PlanetMarker,
 };
 use nova_ship::prelude::GameSections;
 
@@ -41,7 +37,7 @@ use crate::{
     },
     generation::{prepare_cell, PreparedBodies, PreparedPlanetoid, PreparedRock},
     PreparedSector, SectorCoord, SectorFault, SectorGenerator, SectorShip, SectorShipConditionType,
-    SectorShipCrew, WorldConfig,
+    WorldConfig,
 };
 
 /// Marks the entity the desired set is centred on.
@@ -204,7 +200,6 @@ pub fn desired_sectors(centre: SectorCoord, radius: i32) -> BTreeSet,
     sections: &GameSections,
     observer: ObserverBody,
 ) -> Entity {
@@ -248,20 +243,8 @@ pub fn materialize_sector(
             }),
             ChildOf(root),
         ));
-        asteroid_scenario_object_prepared(
-            &mut entity,
-            AsteroidConfig {
-                radius: body.radius,
-                texture: texture.clone(),
-                kind: body.kind.clone(),
-                destroy_sound: None,
-                initial_velocity: body.initial_velocity,
-                lock_signature: None,
-                seed: Some(body.seed),
-            },
-            body.seed,
-            geometry,
-        );
+        let seed = body.seed();
+        asteroid_scenario_object_prepared(&mut entity, body.config, seed, geometry);
     }
 
     for PreparedPlanetoid { body, surface } in planets {
@@ -310,11 +293,8 @@ pub fn materialize_sector(
 ///
 /// On [`SectorFault::InvalidShipDesign`] with every resolver error.
 fn require_resolved(ship: &SectorShip, sections: &GameSections) {
-    // The source is borrowed through a clone because the resolver takes a
-    // `ShipDesignSource`, and the manifest entry keeps its design for a held
-    // ship. Once per ship per materialized cell.
-    let source = ShipDesignSource::Inline(ship.design.clone());
-    let (_, errors) = resolve_ship_design(&source, &GameShipDesigns::default(), sections);
+    let (_, errors) =
+        resolve_ship_design(&ship.spawn.design, &GameShipDesigns::default(), sections);
     if !errors.is_empty() {
         panic!(
             "nova_world: {}",
@@ -326,67 +306,28 @@ fn require_resolved(ship: &SectorShip, sections: &GameSections) {
     }
 }
 
-/// Seconds an Enemy crew of a streamed ship holds fire after it spawns.
-const SECTOR_ENEMY_ENGAGE_DELAY: f32 = 8.0;
-
 /// Spawn one generated ship under `root`, where and as its manifest entry
-/// says, its hold filled with the entry's stock and its balance the entry's
-/// credits.
-///
-/// Its `Name` says whose it is and what it was built for, `
-/// ` or ` derelict, former `. Inspection and the
-/// selected target show it; a distant map blip shows a minted code instead.
-///
-/// An intact ship carries an AI crew on its civilization's side: it patrols
-/// its manifest loop, holding at each waypoint, and fights inside its leash.
-/// An Enemy crew waits [`SECTOR_ENEMY_ENGAGE_DELAY`] before it first engages,
-/// so a cell that streams in around the player does not open fire on the
-/// spawn frame. A derelict has nobody aboard and nobody's side.
-///
-/// A derelict carries [`DerelictShipMarker`] in the same bundle as the root,
-/// so the section spawn sees it and every section but hull and docking port
-/// spawns inactive; its `lootable` config lets a docked ship Take its stock.
+/// says: the entry's `spawn` config is the whole ship, and this adds only
+/// the pose, the identity and, for a derelict, [`DerelictShipMarker`] in
+/// the same bundle as the root, so the section spawn sees it and every
+/// section but hull and docking port spawns inactive.
 fn spawn_sector_ship(commands: &mut Commands, root: Entity, ship: SectorShip) {
     let SectorShip {
         id,
         position,
         rotation,
-        initial_velocity,
         clearance: _,
-        design,
         condition,
-        crew,
         civilization,
         role,
-        stock,
-        credits,
+        spawn,
     } = ship;
-    let derelict = condition == SectorShipConditionType::Derelict;
     let name = match condition {
         SectorShipConditionType::Intact => format!("{} {}", civilization.name(), role.label()),
         SectorShipConditionType::Derelict => {
             format!("{} derelict, former {}", civilization.name(), role.label())
         }
     };
-    let (controller, allegiance) = match crew {
-        Some(SectorShipCrew {
-            allegiance,
-            patrol,
-            stops,
-            leash,
-        }) => (
-            SpaceshipController::AI(AIControllerConfig {
-                patrol,
-                patrol_stops: stops,
-                leash: Some(leash),
-                engage_delay: (allegiance == Allegiance::Enemy)
-                    .then_some(SECTOR_ENEMY_ENGAGE_DELAY),
-                ..default()
-            }),
-            allegiance,
-        ),
-        None => (SpaceshipController::None, Allegiance::Neutral),
-    };
     let ship = (
         base_scenario_object(&BaseScenarioObjectConfig {
             id,
@@ -394,23 +335,13 @@ fn spawn_sector_ship(commands: &mut Commands, root: Entity, ship: SectorShip) {
             position,
             rotation,
         }),
-        spaceship_scenario_object(SpaceshipConfig {
-            design: ShipDesignSource::Inline(design),
-            controller,
-            initial_velocity,
-            allegiance: Some(allegiance),
-            inventory: stock,
-            lootable: derelict,
-            credits,
-            ..default()
-        }),
+        spaceship_scenario_object(spawn),
         ChildOf(root),
     );
-    if derelict {
-        commands.spawn((ship, DerelictShipMarker));
-    } else {
-        commands.spawn(ship);
-    }
+    match condition {
+        SectorShipConditionType::Intact => commands.spawn(ship),
+        SectorShipConditionType::Derelict => commands.spawn((ship, DerelictShipMarker)),
+    };
 }
 
 /// The live sector roots, keyed by cell.
@@ -812,14 +743,13 @@ pub fn materialize_ready_sector(
     };
 
     let coord = prepared.coord();
-    let texture = AssetRef::::Path(ASTEROID_TEXTURE_PATH.to_string());
     let Ok((transform, envelope)) = observer.single() else {
         panic!("nova_world: {}", SectorFault::AbsentObserver);
     };
     let observer = ObserverBody::read(transform, envelope);
     // The job carried the record's bodies; the ledger's copy is spent.
     frozen.consume(coord);
-    materialize_sector(&mut commands, prepared, &texture, §ions, observer);
+    materialize_sector(&mut commands, prepared, §ions, observer);
     stats.materialized += 1;
 
     if desired
diff --git a/crates/nova_world/src/tests.rs b/crates/nova_world/src/tests.rs
index 8c64b9987..3fcad6279 100644
--- a/crates/nova_world/src/tests.rs
+++ b/crates/nova_world/src/tests.rs
@@ -28,12 +28,14 @@ use nova_gameplay::{
     test_support::test_items,
 };
 use nova_scenario::prelude::{
-    AIControllerConfig, AsteroidMarker, AsteroidPlugin, PlanetConfig, PlanetType, SectionSource,
-    ShipDesign, SpaceshipController, SpaceshipPlugin, SpaceshipSectionConfig,
-    ASTEROID_GEOMETRIC_FACTOR_MAX, KIND_ROCK,
+    AIControllerConfig, AsteroidConfig, AsteroidMarker, AsteroidPlugin, PlanetConfig, PlanetType,
+    PlayerControllerConfig, SectionSource, ShipDesign, ShipDesignSource, SpaceshipConfig,
+    SpaceshipController, SpaceshipPlugin, SpaceshipSectionConfig, ASTEROID_GEOMETRIC_FACTOR_MAX,
+    KIND_ROCK,
 };
 use nova_ship::prelude::{
     BaseSectionConfig, GameSections, HullSectionConfig, SectionConfig, SectionKind,
+    ShipCapabilities,
 };
 
 mod frozen;
@@ -42,11 +44,42 @@ use crate::{
     generate_sector, materialize_pending_ships, materialize_sector, prepare_sector, sector_id,
     validate_manifest, CivilizationId, NovaWorldPlugin, ObserverBody, PendingSectorShip,
     SectorAsteroid, SectorCoord, SectorFault, SectorGenerationInput, SectorGenerator,
-    SectorManifest, SectorPlanet, SectorShip, SectorShipConditionType, SectorShipCrew,
-    ShipRoleType, WorldConfig, WorldGeometry, WorldObserver, ACTIVE_WINDOW_SECTORS_MAX,
-    SECTOR_SHIP_CLEARANCE_MAX,
+    SectorManifest, SectorPlanet, SectorShip, SectorShipConditionType, ShipRoleType, WorldConfig,
+    WorldGeometry, WorldObserver, ACTIVE_WINDOW_SECTORS_MAX, SECTOR_SHIP_CLEARANCE_MAX,
 };
 
+/// The texture path every test rock names. Never loaded: no test here renders.
+const TEST_ROCK_TEXTURE: &str = "test/asteroid.png";
+
+/// A rock config of `radius` with silhouette `seed`, at rest.
+fn rock_config(radius: Meters, seed: u32) -> AsteroidConfig {
+    AsteroidConfig {
+        radius,
+        initial_velocity: MetersPerSecond3::ZERO,
+        texture: AssetRef::Path(TEST_ROCK_TEXTURE.to_string()),
+        kind: KIND_ROCK.into(),
+        destroy_sound: None,
+        lock_signature: None,
+        seed: Some(seed),
+    }
+}
+
+/// `ship`'s inline design, to edit.
+fn design_mut(ship: &mut SectorShip) -> &mut ShipDesign {
+    let ShipDesignSource::Inline(design) = &mut ship.spawn.design else {
+        panic!("the fixture ship's design is inline");
+    };
+    design
+}
+
+/// `ship`'s AI crew, to edit.
+fn crew_mut(ship: &mut SectorShip) -> &mut AIControllerConfig {
+    let SpaceshipController::AI(crew) = &mut ship.spawn.controller else {
+        panic!("the fixture ship is AI-flown");
+    };
+    crew
+}
+
 /// The section prototype every test ship is built from.
 const TEST_HULL_SECTION_ID: &str = "test_hull";
 
@@ -81,10 +114,7 @@ impl SectorGenerator for Rocks {
             .map(|(index, (x, z))| SectorAsteroid {
                 id: sector_id(input.coord, "body", index),
                 position: input.coord.centre(edge) + Meters3::new(x * quarter, 0.0, z * quarter),
-                radius: self.radius_max,
-                initial_velocity: MetersPerSecond3::ZERO,
-                kind: KIND_ROCK.into(),
-                seed: index as u32,
+                config: rock_config(self.radius_max, index as u32),
             })
             .collect();
         Ok(empty(input.coord, asteroids))
@@ -117,37 +147,41 @@ fn empty(coord: SectorCoord, asteroids: Vec) -> SectorManifest {
 }
 
 /// A one-section intact ship at `position`, built from the section prototype
-/// `prototype`, with a 20 m clearance.
+/// `prototype`, with a 20 m clearance, flown by a routeless Neutral AI crew
+/// on a 5 km leash, with an empty hold and 120 credits.
 fn ship(id: String, position: Meters3, prototype: &str) -> SectorShip {
     SectorShip {
         id,
         position,
-        initial_velocity: MetersPerSecond3::ZERO,
         rotation: Quat::IDENTITY,
         clearance: Meters(20.0),
-        design: ShipDesign {
-            sections: vec![SpaceshipSectionConfig {
-                id: "hull".into(),
-                position: Vec3::ZERO,
-                rotation: Quat::IDENTITY,
-                source: SectionSource::prototype(prototype),
-            }],
-            ..default()
-        },
         condition: SectorShipConditionType::Intact,
-        crew: Some(SectorShipCrew {
-            allegiance: Allegiance::Neutral,
-            patrol: Vec::new(),
-            stops: Vec::new(),
-            leash: Meters(5_000.0),
-        }),
         civilization: CivilizationId {
             world_seed: 0,
             node: [0, 0, 0],
         },
         role: ShipRoleType::Civilian,
-        stock: ShipInventoryStock::default(),
-        credits: 120,
+        spawn: SpaceshipConfig {
+            design: ShipDesignSource::Inline(ShipDesign {
+                sections: vec![SpaceshipSectionConfig {
+                    id: "hull".into(),
+                    position: Vec3::ZERO,
+                    rotation: Quat::IDENTITY,
+                    source: SectionSource::prototype(prototype),
+                }],
+                ..default()
+            }),
+            controller: SpaceshipController::AI(AIControllerConfig {
+                leash: Some(Meters(5_000.0)),
+                ..default()
+            }),
+            initial_velocity: MetersPerSecond3::ZERO,
+            allegiance: Some(Allegiance::Neutral),
+            capabilities: ShipCapabilities::default(),
+            inventory: ShipInventoryStock::default(),
+            lootable: false,
+            credits: 120,
+        },
     }
 }
 
@@ -224,17 +258,14 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
             id: format!("{}_{id}", input.coord.slug()),
             position: input.coord.centre(input.geometry.sector_edge)
                 + Meters3::new(offset, 0.0, 0.0),
-            radius: Meters(40.0),
-            initial_velocity: MetersPerSecond3::ZERO,
-            kind: KIND_ROCK.into(),
-            seed: 7,
+            config: rock_config(Meters(40.0), 7),
         }
     }
     let cases: [(
         &str,
         fn(SectorGenerationInput) -> SectorManifest,
         fn(&SectorFault) -> bool,
-    ); 18] = [
+    ); 23] = [
         (
             "the wrong cell",
             |input| empty(input.coord.offset(1, 0, 0), Vec::new()),
@@ -294,7 +325,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
             "an asteroid kind the game does not ship",
             |input| {
                 let mut body = rock(input, "body_0", 0.0);
-                body.kind = "obsidian".into();
+                body.config.kind = "obsidian".into();
                 empty(input.coord, vec![body])
             },
             |fault| matches!(fault, SectorFault::UnknownKind { kind } if kind.as_str() == "obsidian"),
@@ -303,7 +334,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
             "an asteroid with a non-finite initial velocity",
             |input| {
                 let mut body = rock(input, "body_0", 0.0);
-                body.initial_velocity = MetersPerSecond3::new(f32::NAN, 0.0, 0.0);
+                body.config.initial_velocity = MetersPerSecond3::new(f32::NAN, 0.0, 0.0);
                 empty(input.coord, vec![body])
             },
             |fault| {
@@ -316,11 +347,38 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
                 )
             },
         ),
+        (
+            "an asteroid that pins no silhouette seed",
+            |input| {
+                let mut body = rock(input, "body_0", 0.0);
+                body.config.seed = None;
+                empty(input.coord, vec![body])
+            },
+            |fault| matches!(fault, SectorFault::Manifest { field: "seed", .. }),
+        ),
+        (
+            "an asteroid whose texture is a runtime handle",
+            |input| {
+                let mut body = rock(input, "body_0", 0.0);
+                body.config.texture = AssetRef::Handle(Handle::default());
+                empty(input.coord, vec![body])
+            },
+            |fault| {
+                matches!(
+                    fault,
+                    SectorFault::Manifest {
+                        field: "texture",
+                        ..
+                    }
+                )
+            },
+        ),
         (
             "a ship with a non-finite initial velocity",
             |input| {
                 let mut manifest = one_ship(input);
-                manifest.ships[0].initial_velocity = MetersPerSecond3::new(0.0, f32::INFINITY, 0.0);
+                manifest.ships[0].spawn.initial_velocity =
+                    MetersPerSecond3::new(0.0, f32::INFINITY, 0.0);
                 manifest
             },
             |fault| {
@@ -359,7 +417,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
             "a ship with no sections",
             |input| {
                 let mut manifest = one_ship(input);
-                manifest.ships[0].design.sections.clear();
+                design_mut(&mut manifest.ships[0]).sections.clear();
                 manifest
             },
             |fault| {
@@ -408,48 +466,124 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
             },
         ),
         (
-            "an intact ship with no crew",
+            "a ship whose design names a catalog prototype",
+            |input| {
+                let mut manifest = one_ship(input);
+                manifest.ships[0].spawn.design = ShipDesignSource::prototype("corvette");
+                manifest
+            },
+            |fault| {
+                matches!(
+                    fault,
+                    SectorFault::Manifest {
+                        field: "design",
+                        ..
+                    }
+                )
+            },
+        ),
+        (
+            "a ship with no side",
+            |input| {
+                let mut manifest = one_ship(input);
+                manifest.ships[0].spawn.allegiance = None;
+                manifest
+            },
+            |fault| {
+                matches!(
+                    fault,
+                    SectorFault::Manifest {
+                        field: "allegiance",
+                        ..
+                    }
+                )
+            },
+        ),
+        (
+            "a ship flown by a player",
             |input| {
                 let mut manifest = one_ship(input);
-                manifest.ships[0].crew = None;
+                manifest.ships[0].spawn.controller =
+                    SpaceshipController::Player(PlayerControllerConfig::default());
                 manifest
             },
-            |fault| matches!(fault, SectorFault::Manifest { field: "crew", .. }),
+            |fault| {
+                matches!(
+                    fault,
+                    SectorFault::Manifest {
+                        field: "controller",
+                        ..
+                    }
+                )
+            },
         ),
         (
-            "a derelict with a crew",
+            "a derelict flown by an AI",
             |input| {
                 let mut manifest = one_ship(input);
                 manifest.ships[0].condition = SectorShipConditionType::Derelict;
                 manifest
             },
-            |fault| matches!(fault, SectorFault::Manifest { field: "crew", .. }),
+            |fault| {
+                matches!(
+                    fault,
+                    SectorFault::Manifest {
+                        field: "controller",
+                        ..
+                    }
+                )
+            },
         ),
         (
             "a crew with a stop for a missing waypoint",
             |input| {
                 let mut manifest = one_ship(input);
-                manifest.ships[0]
-                    .crew
-                    .as_mut()
-                    .expect("the fixture ship is crewed")
-                    .stops = vec![30.0];
+                crew_mut(&mut manifest.ships[0]).patrol_stops = vec![30.0];
+                manifest
+            },
+            |fault| {
+                matches!(
+                    fault,
+                    SectorFault::Manifest {
+                        field: "controller",
+                        ..
+                    }
+                )
+            },
+        ),
+        (
+            "a crew with a waypoint that is not finite",
+            |input| {
+                let mut manifest = one_ship(input);
+                crew_mut(&mut manifest.ships[0]).patrol = vec![Meters3::new(f32::NAN, 0.0, 0.0)];
                 manifest
             },
-            |fault| matches!(fault, SectorFault::Manifest { field: "crew", .. }),
+            |fault| {
+                matches!(
+                    fault,
+                    SectorFault::Manifest {
+                        field: "controller",
+                        ..
+                    }
+                )
+            },
         ),
         (
             "a crew with a zero leash",
             |input| {
                 let mut manifest = one_ship(input);
-                manifest.ships[0]
-                    .crew
-                    .as_mut()
-                    .expect("the fixture ship is crewed")
-                    .leash = Meters(0.0);
+                crew_mut(&mut manifest.ships[0]).leash = Some(Meters(0.0));
                 manifest
             },
-            |fault| matches!(fault, SectorFault::Manifest { field: "crew", .. }),
+            |fault| {
+                matches!(
+                    fault,
+                    SectorFault::Manifest {
+                        field: "controller",
+                        ..
+                    }
+                )
+            },
         ),
     ];
     for (what, answer, expected) in cases {
@@ -474,10 +608,7 @@ fn bodies_closer_than_a_generator_margin_but_not_overlapping_are_accepted() {
             let rock = |index: usize, offset: f32| SectorAsteroid {
                 id: sector_id(input.coord, "body", index),
                 position: centre + Meters3::new(offset, 0.0, 0.0),
-                radius: Meters(40.0),
-                initial_velocity: MetersPerSecond3::ZERO,
-                kind: KIND_ROCK.into(),
-                seed: index as u32,
+                config: rock_config(Meters(40.0), index as u32),
             };
             empty(input.coord, vec![rock(0, 0.0), rock(1, 580.0)])
         }),
@@ -504,10 +635,7 @@ fn populated(input: SectorGenerationInput, rocks: usize, count: usize) -> Sector
         .map(|index| SectorAsteroid {
             id: sector_id(coord, "body", index),
             position: point(index),
-            radius: Meters(40.0),
-            initial_velocity: MetersPerSecond3::ZERO,
-            kind: KIND_ROCK.into(),
-            seed: index as u32,
+            config: rock_config(Meters(40.0), index as u32),
         })
         .collect();
     let mut manifest = empty(coord, asteroids);
@@ -620,10 +748,10 @@ fn canonical_text_tells_asteroid_velocities_apart() {
         let body = SectorAsteroid {
             id: sector_id(input.coord, "body", 0),
             position: input.coord.centre(input.geometry.sector_edge),
-            radius: Meters(40.0),
-            initial_velocity,
-            kind: KIND_ROCK.into(),
-            seed: 7,
+            config: AsteroidConfig {
+                initial_velocity,
+                ..rock_config(Meters(40.0), 7)
+            },
         };
         validate_manifest(input, empty(input.coord, vec![body]))
             .expect("a valid rock must describe")
@@ -648,10 +776,10 @@ fn a_materialized_asteroid_opts_into_gravity() {
         let rock = SectorAsteroid {
             id: sector_id(input.coord, "body", 0),
             position: input.coord.centre(input.geometry.sector_edge),
-            radius: Meters(60.0),
-            initial_velocity: MetersPerSecond3::new(24.0, -8.0, 4.0),
-            kind: KIND_ROCK.into(),
-            seed: 7,
+            config: AsteroidConfig {
+                initial_velocity: MetersPerSecond3::new(24.0, -8.0, 4.0),
+                ..rock_config(Meters(60.0), 7)
+            },
         };
         empty(input.coord, vec![rock])
     });
@@ -664,7 +792,6 @@ fn a_materialized_asteroid_opts_into_gravity() {
     let root = materialize_sector(
         &mut world.commands(),
         prepared,
-        &AssetRef::default(),
         &GameSections::default(),
         ObserverBody {
             position: Meters3::new(0.0, 0.0, 0.0),
@@ -735,32 +862,44 @@ fn canonical_text_describes_a_rotation_and_its_negation_alike() {
 
 /// Every ship field that changes what spawns, or whose ship it is, is part of
 /// "the same sector": the design's integrity and presentation, a section's
-/// prototype patch, the civilization and the role each describe a different
-/// cell. A non-finite value prints apart from `None` and from another
-/// non-finite value, never as one shared `null`.
+/// prototype patch, the side, the loot flag, the controller and its grace,
+/// the civilization and the role each describe a different cell. A
+/// non-finite value prints apart from `None` and from another non-finite
+/// value, never as one shared `null`.
 #[test]
 fn canonical_text_tells_ship_design_fields_and_identity_apart() {
     fn patch_health(ship: &mut SectorShip, health: f32) {
-        let SectionSource::Prototype { patch, .. } = &mut ship.design.sections[0].source else {
+        let SectionSource::Prototype { patch, .. } = &mut design_mut(ship).sections[0].source
+        else {
             panic!("the fixture ship's hull is a prototype reference");
         };
         patch.health = Some(health);
     }
     let input = rocks().input(SectorCoord::ORIGIN);
-    let variants: [(&str, fn(&mut SectorShip)); 11] = [
+    let variants: [(&str, fn(&mut SectorShip)); 15] = [
         ("as built", |_| {}),
         ("collapse 0.1", |ship| {
-            ship.design.integrity.collapse_threshold = Some(0.1);
+            design_mut(ship).integrity.collapse_threshold = Some(0.1);
         }),
         ("collapse NaN", |ship| {
-            ship.design.integrity.collapse_threshold = Some(f32::NAN);
+            design_mut(ship).integrity.collapse_threshold = Some(f32::NAN);
         }),
         ("collapse inf", |ship| {
-            ship.design.integrity.collapse_threshold = Some(f32::INFINITY);
+            design_mut(ship).integrity.collapse_threshold = Some(f32::INFINITY);
         }),
-        ("skinned", |ship| ship.design.presentation.skin = true),
+        ("skinned", |ship| design_mut(ship).presentation.skin = true),
         ("styled", |ship| {
-            ship.design.presentation.style = Some("armoured".to_string());
+            design_mut(ship).presentation.style = Some("armoured".to_string());
+        }),
+        ("another side", |ship| {
+            ship.spawn.allegiance = Some(Allegiance::Enemy);
+        }),
+        ("lootable", |ship| ship.spawn.lootable = true),
+        ("unflown", |ship| {
+            ship.spawn.controller = SpaceshipController::None
+        }),
+        ("holding fire", |ship| {
+            crew_mut(ship).engage_delay = Some(8.0);
         }),
         ("patched health 50", |ship| patch_health(ship, 50.0)),
         ("patched health NaN", |ship| patch_health(ship, f32::NAN)),
@@ -809,7 +948,6 @@ fn materialize_one_ship(world: &mut World, offset: f32) -> Entity {
     let root = materialize_sector(
         &mut world.commands(),
         prepared,
-        &AssetRef::default(),
         &test_sections(),
         observer_at(config.input(SectorCoord::ORIGIN), offset),
     );
@@ -890,35 +1028,38 @@ fn a_ship_the_observer_overlaps_is_held_until_the_observer_is_clear() {
 fn intact_and_derelict(input: SectorGenerationInput) -> SectorManifest {
     let centre = input.coord.centre(input.geometry.sector_edge);
     let mut manifest = one_ship(input);
-    manifest.ships[0].crew = Some(SectorShipCrew {
-        allegiance: Allegiance::Enemy,
+    manifest.ships[0].spawn.allegiance = Some(Allegiance::Enemy);
+    *crew_mut(&mut manifest.ships[0]) = AIControllerConfig {
         patrol: vec![
             centre + Meters3::new(0.0, 0.0, 1_500.0),
             centre + Meters3::new(0.0, 0.0, -1_500.0),
         ],
-        stops: vec![30.0, 45.0],
-        leash: Meters(5_000.0),
-    });
-    manifest.ships.push(SectorShip {
-        condition: SectorShipConditionType::Derelict,
-        crew: None,
-        stock: ShipInventoryStock::new([(ITEM_HULL_PLATE.into(), 5)]),
-        credits: 15,
-        ..ship(
-            sector_id(input.coord, "ship", 1),
-            centre + Meters3::new(1_000.0, 0.0, 0.0),
-            TEST_HULL_SECTION_ID,
-        )
-    });
+        patrol_stops: vec![30.0, 45.0],
+        leash: Some(Meters(5_000.0)),
+        engage_delay: Some(8.0),
+        ..default()
+    };
+    let mut derelict = ship(
+        sector_id(input.coord, "ship", 1),
+        centre + Meters3::new(1_000.0, 0.0, 0.0),
+        TEST_HULL_SECTION_ID,
+    );
+    derelict.condition = SectorShipConditionType::Derelict;
+    derelict.spawn.controller = SpaceshipController::None;
+    derelict.spawn.lootable = true;
+    derelict.spawn.inventory = ShipInventoryStock::new([(ITEM_HULL_PLATE.into(), 5)]);
+    derelict.spawn.credits = 15;
+    manifest.ships.push(derelict);
     manifest
 }
 
-/// A derelict spawns lootable with its manifest stock, so a docked ship may
-/// Take it, and flown by nobody on nobody's side; an intact ship spawns
-/// neither lootable nor stocked, flown by an AI on its crew's side along its
-/// crew's loop, an Enemy crew holding fire for its arrival grace. Each spawns
-/// with its manifest credits. Through the real ship spawn: the side and the
-/// loot marker land when the section observer applies the spawn policy.
+/// A ship spawns as its manifest entry says and materialization adds nothing:
+/// the derelict lootable with its stock, so a docked ship may Take it, flown
+/// by nobody on nobody's side and marked a derelict; the intact ship neither
+/// lootable nor stocked, flown by an AI on the entry's side along the entry's
+/// loop with its leash and its arrival grace. Each spawns with its entry's
+/// credits. Through the real ship spawn: the side and the loot marker land
+/// when the section observer applies the spawn policy.
 #[test]
 fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_by_its_crew() {
     let config = answering(intact_and_derelict);
@@ -932,7 +1073,6 @@ fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_b
     materialize_sector(
         &mut world.commands(),
         prepared,
-        &AssetRef::default(),
         &test_sections(),
         observer_at(config.input(SectorCoord::ORIGIN), 5_000.0),
     );
@@ -1001,11 +1141,7 @@ fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_b
     );
     assert_eq!(crew.patrol_stops, [30.0, 45.0]);
     assert_eq!(crew.leash, Some(Meters(5_000.0)));
-    assert_eq!(
-        crew.engage_delay,
-        Some(8.0),
-        "an Enemy crew arrives holding fire"
-    );
+    assert_eq!(crew.engage_delay, Some(8.0));
     assert_eq!(*derelict_side, Allegiance::Neutral);
 }
 
@@ -1019,8 +1155,7 @@ fn a_ship_design_the_loaded_sections_do_not_resolve_is_refused_at_spawn() {
     let prepared = prepare_sector(
         answering(|input| {
             let mut manifest = one_ship(input);
-            manifest.ships[0]
-                .design
+            design_mut(&mut manifest.ships[0])
                 .sections
                 .push(SpaceshipSectionConfig {
                     id: "missing".into(),
@@ -1037,7 +1172,6 @@ fn a_ship_design_the_loaded_sections_do_not_resolve_is_refused_at_spawn() {
     materialize_sector(
         &mut world.commands(),
         prepared,
-        &AssetRef::default(),
         &test_sections(),
         ObserverBody {
             position: Meters3::new(0.0, 0.0, 0.0),
diff --git a/crates/nova_world/src/tests/frozen.rs b/crates/nova_world/src/tests/frozen.rs
index 0651c42f6..658c11b1b 100644
--- a/crates/nova_world/src/tests/frozen.rs
+++ b/crates/nova_world/src/tests/frozen.rs
@@ -9,9 +9,7 @@ use std::time::Duration;
 
 use avian3d::prelude::*;
 use bevy::{ecs::system::RunSystemOnce, prelude::*};
-use nova_events::prelude::{
-    EntityId, Meters, Meters3, MetersPerSecond3, ScenarioAddressableMarker,
-};
+use nova_events::prelude::{EntityId, Meters, Meters3, ScenarioAddressableMarker};
 use nova_gameplay::{
     prelude::{
         AssetRef, CargoCanister, CargoCanisterRuntimeId, DamageMark, DamageMarks, Health,
@@ -23,7 +21,7 @@ use nova_gameplay::{
 use nova_scenario::prelude::{
     freeze_ship, AsteroidCarvePlugin, AsteroidField, AsteroidMarker, AsteroidPlugin, SectionSource,
     ShipDesign, ShipDesignSource, SpaceshipController, SpaceshipDesign, SpaceshipPlugin,
-    SpaceshipSectionConfig, KIND_ROCK,
+    SpaceshipSectionConfig,
 };
 use nova_ship::prelude::{
     cargo_canister, docking_section, thruster_section, AINonCombatant, BaseSectionConfig,
@@ -33,7 +31,10 @@ use nova_ship::prelude::{
     SpaceshipSectionPlugin, ThrusterSectionConfig, TurretSectionConfig,
 };
 
-use super::{answering, empty, rocks, ship, test_sections, Answers, Rocks, TEST_HULL_SECTION_ID};
+use super::{
+    answering, design_mut, empty, rock_config, rocks, ship, test_sections, Answers, Rocks,
+    TEST_HULL_SECTION_ID,
+};
 use crate::{
     adopt_moving_bodies, clear_sector_work, frozen::SettlingBodies, generation::prepare_cell,
     materialize_sector, retire_sectors, sector_id, streaming::ClearedConfig, CurrentSector,
@@ -84,13 +85,7 @@ fn materialize(
         .get_resource::()
         .cloned()
         .unwrap_or_default();
-    let root = materialize_sector(
-        &mut world.commands(),
-        prepared,
-        &AssetRef::Path(nova_assets::prelude::ASTEROID_TEXTURE_PATH.to_string()),
-        §ions,
-        observer,
-    );
+    let root = materialize_sector(&mut world.commands(), prepared, §ions, observer);
     world.flush();
     root
 }
@@ -613,23 +608,21 @@ fn wreckable(input: SectorGenerationInput) -> SectorManifest {
     let rock = SectorAsteroid {
         id: sector_id(input.coord, "body", 0),
         position: centre + Meters3::new(-quarter, 0.0, 0.0),
-        radius: Meters(60.0),
-        initial_velocity: MetersPerSecond3::ZERO,
-        kind: KIND_ROCK.into(),
-        seed: 3,
+        config: rock_config(Meters(60.0), 3),
     };
     let mut fought = ship(
         sector_id(input.coord, "ship", 0),
         centre + Meters3::new(quarter, 0.0, 0.0),
         TEST_HULL_SECTION_ID,
     );
-    let mut aft = fought.design.sections[0].clone();
-    fought.design.sections[0].id = "fore".into();
+    let design = design_mut(&mut fought);
+    let mut aft = design.sections[0].clone();
+    design.sections[0].id = "fore".into();
     aft.id = "aft".into();
     aft.position = Vec3::Z;
     aft.source = SectionSource::prototype(TEST_TURRET_SECTION_ID);
-    fought.design.sections.push(aft);
-    fought.stock = ShipInventoryStock::new([(ITEM_IRON_ORE.into(), 10)]);
+    design.sections.push(aft);
+    fought.spawn.inventory = ShipInventoryStock::new([(ITEM_IRON_ORE.into(), 10)]);
     let held = ship(
         sector_id(input.coord, "ship", 1),
         centre + Meters3::new(0.0, 0.0, quarter),
diff --git a/crates/nova_world_base/src/clusters.rs b/crates/nova_world_base/src/clusters.rs
index 821d4bdbd..2f0e5d8cb 100644
--- a/crates/nova_world_base/src/clusters.rs
+++ b/crates/nova_world_base/src/clusters.rs
@@ -65,14 +65,16 @@
 //! small background scatter instead, so open space is not always empty.
 
 use bevy::prelude::{Quat, Vec3};
+use nova_assets::prelude::ASTEROID_TEXTURE_PATH;
 use nova_events::prelude::{Meters, Meters3, MetersPerSecond3, MetersPerSecondSquared};
 use nova_gameplay::prelude::{
-    circular_orbit_speed, unit_sphere_point, Fnv32, GameItems, GravitySettings, GravityWell,
-    SeedStream,
+    circular_orbit_speed, unit_sphere_point, AssetRef, Fnv32, GameItems, GravitySettings,
+    GravityWell, SeedStream,
 };
 use nova_scenario::prelude::{
-    asteroid_seed_from_id, PlanetConfig, PlanetType, ASTEROID_GEOMETRIC_FACTOR_MAX, KIND_CARBON,
-    KIND_ICE, KIND_METAL, KIND_ROCK,
+    asteroid_seed_from_id, AsteroidConfig, PlanetConfig, PlanetType, SpaceshipConfig,
+    SpaceshipController, ASTEROID_GEOMETRIC_FACTOR_MAX, KIND_CARBON, KIND_ICE, KIND_METAL,
+    KIND_ROCK,
 };
 use nova_world::prelude::*;
 
@@ -957,20 +959,25 @@ impl SectorPlan {
             let position = planned.position;
             match &planned.body {
                 ClusterBody::Rock { radius, kind } => manifest.asteroids.push(SectorAsteroid {
-                    seed: asteroid_seed_from_id(&id),
+                    config: AsteroidConfig {
+                        radius: *radius,
+                        initial_velocity: generated_initial_velocity(
+                            &planned.id,
+                            position,
+                            Meters(radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
+                            self.coord,
+                            self.edge,
+                            &wells,
+                            &settings,
+                        )?,
+                        texture: AssetRef::Path(ASTEROID_TEXTURE_PATH.to_string()),
+                        kind: (*kind).into(),
+                        destroy_sound: None,
+                        lock_signature: None,
+                        seed: Some(asteroid_seed_from_id(&id)),
+                    },
                     id,
                     position,
-                    radius: *radius,
-                    kind: (*kind).into(),
-                    initial_velocity: generated_initial_velocity(
-                        &planned.id,
-                        position,
-                        Meters(radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
-                        self.coord,
-                        self.edge,
-                        &wells,
-                        &settings,
-                    )?,
                 }),
                 ClusterBody::Planetoid(config) => manifest.planets.push(SectorPlanet {
                     id,
@@ -981,23 +988,22 @@ impl SectorPlan {
                     id,
                     position,
                     rotation: ship.rotation,
-                    initial_velocity: generated_initial_velocity(
-                        &planned.id,
-                        position,
-                        ship.clearance,
-                        self.coord,
-                        self.edge,
-                        &wells,
-                        &settings,
-                    )?,
                     clearance: ship.clearance,
-                    design: ship.design.clone(),
                     condition: ship.condition,
                     civilization: ship.civilization,
                     role: ship.role,
-                    stock: ship.stock.clone(),
-                    credits: ship.credits,
-                    crew: ship.crew.clone(),
+                    spawn: SpaceshipConfig {
+                        initial_velocity: generated_initial_velocity(
+                            &planned.id,
+                            position,
+                            ship.clearance,
+                            self.coord,
+                            self.edge,
+                            &wells,
+                            &settings,
+                        )?,
+                        ..ship.spawn.clone()
+                    },
                 }),
                 ClusterBody::Hull { .. } => {
                     unreachable!("a cell lays out every hull it owns before it resolves them")
@@ -1058,10 +1064,10 @@ impl SectorPlan {
                 .iter()
                 .filter(|body| body.skipped.is_none())
                 .filter(|body| match &body.body {
-                    ClusterBody::Ship(ship) => ship
-                        .crew
-                        .as_ref()
-                        .is_some_and(|crew| crew.patrol.is_empty()),
+                    ClusterBody::Ship(ship) => matches!(
+                        &ship.spawn.controller,
+                        SpaceshipController::AI(crew) if crew.patrol.is_empty()
+                    ),
                     _ => false,
                 })
                 .count(),
@@ -1703,7 +1709,7 @@ fn plan_patrols(input: SectorGenerationInput, bodies: &mut [PlannedBody]) {
         };
         let clearance = ship.clearance;
         let rotation = ship.rotation;
-        let Some(crew) = ship.crew.as_mut() else {
+        let SpaceshipController::AI(crew) = &mut ship.spawn.controller else {
             continue;
         };
         let others: Vec<(Meters3, Meters)> = standing
@@ -1711,7 +1717,7 @@ fn plan_patrols(input: SectorGenerationInput, bodies: &mut [PlannedBody]) {
             .filter(|(other, ..)| *other != index)
             .map(|&(_, position, clearance)| (position, clearance))
             .collect();
-        (crew.patrol, crew.stops) = plan_patrol(
+        (crew.patrol, crew.patrol_stops) = plan_patrol(
             input.seed,
             node,
             slot,
@@ -2723,6 +2729,7 @@ mod tests {
                     .iter()
                     .find(|rock| rock.id == id)
                     .unwrap()
+                    .config
                     .initial_velocity,
                 MetersPerSecond3::ZERO
             );
@@ -2814,13 +2821,13 @@ mod tests {
                         .asteroids
                         .iter()
                         .find(|rock| rock.id == body.id)
-                        .map(|rock| rock.initial_velocity)
+                        .map(|rock| rock.config.initial_velocity)
                         .or_else(|| {
                             manifest
                                 .ships
                                 .iter()
                                 .find(|ship| ship.id == body.id)
-                                .map(|ship| ship.initial_velocity)
+                                .map(|ship| ship.spawn.initial_velocity)
                         })
                         .expect("every placed mobile body is in the manifest");
                     assert!(velocity.is_finite(), "{} velocity", body.id);
diff --git a/crates/nova_world_base/src/sector_ships.rs b/crates/nova_world_base/src/sector_ships.rs
index bbbbbb293..1953a18ca 100644
--- a/crates/nova_world_base/src/sector_ships.rs
+++ b/crates/nova_world_base/src/sector_ships.rs
@@ -36,14 +36,17 @@
 //! travel.
 
 use bevy::prelude::{Quat, Vec3};
-use nova_events::prelude::{Meters, Meters3};
+use nova_events::prelude::{Meters, Meters3, MetersPerSecond3};
 use nova_gameplay::prelude::{
-    Fnv32, GameItems, ItemDesignId, SeedStream, ShipInventoryStock, ITEM_CARBON_ORE,
+    Allegiance, Fnv32, GameItems, ItemDesignId, SeedStream, ShipInventoryStock, ITEM_CARBON_ORE,
     ITEM_HULL_PLATE, ITEM_IRON_ORE, ITEM_PDC_ROUND, ITEM_RAIL_SLUG, ITEM_RATIONS,
     ITEM_SALVAGED_PARTS, ITEM_STONE_ORE, ITEM_TORPEDO, ITEM_WATER_ICE,
 };
-use nova_scenario::prelude::{ShipDesign, HULL_SECTION_CARGO_G};
-use nova_ship::prelude::SectionKind;
+use nova_scenario::prelude::{
+    AIControllerConfig, ShipDesign, ShipDesignSource, SpaceshipConfig, SpaceshipController,
+    HULL_SECTION_CARGO_G,
+};
+use nova_ship::prelude::{SectionKind, ShipCapabilities};
 use nova_world::prelude::*;
 
 use crate::{
@@ -83,6 +86,10 @@ const PATROL_STOP: (f32, f32) = (30.0, 60.0);
 /// How far a generated crew chases from its patrol centre.
 const CREW_LEASH: Meters = Meters(5_000.0);
 
+/// Seconds an Enemy crew holds fire after it spawns, so a cell that streams
+/// in around the player does not open fire on the spawn frame.
+const ENEMY_ENGAGE_DELAY: f32 = 8.0;
+
 /// The share of its hold an intact ship's stock aims for, as a `(least,
 /// most)` band at advancement 0 and at advancement 1, interpolated linearly
 /// between. A target, not a fill: item masses round it down.
@@ -128,8 +135,6 @@ pub struct HullSlot<'a> {
 /// fits.
 #[derive(Clone, Debug)]
 pub struct PlannedShip {
-    /// The generated design.
-    pub design: ShipDesign,
     /// Its full orientation.
     pub rotation: Quat,
     /// The radius around its root that holds the hull.
@@ -140,13 +145,14 @@ pub struct PlannedShip {
     pub civilization: CivilizationId,
     /// The role it was laid out for. A wreck keeps the role it was built for.
     pub role: ShipRoleType,
-    /// What its hold carries, see [`ship_stock`].
-    pub stock: ShipInventoryStock,
-    /// Its credit balance.
-    pub credits: u32,
-    /// Who flies it: `Some` exactly when it is intact, with no patrol until
-    /// the cell gives it one with `plan_patrol` after it places every body.
-    pub crew: Option,
+    /// The ship as the spawn builds it: the generated design inline, the
+    /// stock of [`ship_stock`], the balance, and the crew. An intact ship is
+    /// flown by an AI on its civilization's side with no patrol until the
+    /// cell gives it one with `plan_patrol` after it places every body, and
+    /// an Enemy crew waits [`ENEMY_ENGAGE_DELAY`] before it first engages. A
+    /// derelict is flown by nobody, Neutral and lootable. At rest: the cell
+    /// sets the initial motion once it knows the wells around it.
+    pub spawn: SpaceshipConfig,
 }
 
 /// Lay out the ship `hull` stands for.
@@ -279,23 +285,38 @@ pub fn plan_ship(
         SectorShipConditionType::Derelict => uniform_rotation(turn),
     };
 
-    let crew = (condition == SectorShipConditionType::Intact).then(|| SectorShipCrew {
-        allegiance: civilization.allegiance,
-        patrol: Vec::new(),
-        stops: Vec::new(),
-        leash: CREW_LEASH,
-    });
+    let (controller, allegiance, lootable) = match condition {
+        SectorShipConditionType::Intact => (
+            SpaceshipController::AI(AIControllerConfig {
+                patrol: Vec::new(),
+                patrol_stops: Vec::new(),
+                leash: Some(CREW_LEASH),
+                engage_delay: (civilization.allegiance == Allegiance::Enemy)
+                    .then_some(ENEMY_ENGAGE_DELAY),
+                ..AIControllerConfig::default()
+            }),
+            civilization.allegiance,
+            false,
+        ),
+        SectorShipConditionType::Derelict => (SpaceshipController::None, Allegiance::Neutral, true),
+    };
 
     Ok(PlannedShip {
-        design: layout.design,
         rotation,
         clearance: layout.clearance,
         condition,
         civilization: civilization.id,
         role,
-        stock,
-        credits,
-        crew,
+        spawn: SpaceshipConfig {
+            design: ShipDesignSource::Inline(layout.design),
+            controller,
+            initial_velocity: MetersPerSecond3::ZERO,
+            allegiance: Some(allegiance),
+            capabilities: ShipCapabilities::default(),
+            inventory: stock,
+            lootable,
+            credits,
+        },
     })
 }
 
diff --git a/crates/nova_world_base/src/tests.rs b/crates/nova_world_base/src/tests.rs
index 2f39d299f..3c3419c8b 100644
--- a/crates/nova_world_base/src/tests.rs
+++ b/crates/nova_world_base/src/tests.rs
@@ -2,11 +2,11 @@ use std::{collections::BTreeMap, panic::AssertUnwindSafe};
 
 use bevy::ecs::system::RunSystemOnce;
 use nova_assets::prelude::{ContentCatalogDigest, LoadedSectionPack};
-use nova_events::prelude::Meters3;
-use nova_gameplay::prelude::{Fnv64, ItemDesignId, ITEM_HULL_PLATE};
+use nova_events::prelude::{Meters, Meters3};
+use nova_gameplay::prelude::{Allegiance, Fnv64, ItemDesignId, ITEM_HULL_PLATE};
 use nova_scenario::prelude::{
     resolve_ship_design, GameShipDesigns, ScenarioConfig, SectionSource, ShipDesign,
-    ShipDesignSource, SpaceshipSectionConfig,
+    SpaceshipController, SpaceshipSectionConfig,
 };
 use nova_ship::prelude::{GameSections, ShipStyleConfig};
 
@@ -134,7 +134,10 @@ fn the_open_world_describes_the_same_sectors_in_any_visit_order() {
 /// Every planned ship is a goods and credit source: each in the live window
 /// carries stock its resolved hold fits and a positive balance in its
 /// condition's band, and a wreck's mixed stock need not hold a plate. A hold
-/// that cannot fit one unit of its role's lightest item gets no stock.
+/// that cannot fit one unit of its role's lightest item gets no stock. The
+/// generator states the whole spawn: an intact ship is AI-flown on its
+/// civilization's side with the crew leash, an Enemy crew arrives holding
+/// fire, and a wreck is unflown, Neutral and lootable.
 #[test]
 fn every_planned_ship_carries_goods_its_hold_fits_and_credits() {
     let config = session_config();
@@ -152,25 +155,40 @@ fn every_planned_ship_carries_goods_its_hold_fits_and_credits() {
         let description = generate_sector(&config, coord)
             .unwrap_or_else(|fault| panic!("sector {coord:?}: {fault}"));
         for ship in description.ships() {
-            let stacks: Vec<(&ItemDesignId, u32)> = ship.stock.stacks().collect();
+            let spawn = &ship.spawn;
+            let stacks: Vec<(&ItemDesignId, u32)> = spawn.inventory.stacks().collect();
             assert!(!stacks.is_empty(), "ship {} carries no goods", ship.id);
-            let (resolved, errors) = resolve_ship_design(
-                &ShipDesignSource::Inline(ship.design.clone()),
-                &GameShipDesigns::default(),
-                §ions,
-            );
+            let (resolved, errors) =
+                resolve_ship_design(&spawn.design, &GameShipDesigns::default(), §ions);
             assert!(errors.is_empty(), "ship {}: {errors:?}", ship.id);
             assert!(
-                ship.stock.mass_g(items) <= u64::from(resolved.cargo_capacity_g()),
+                spawn.inventory.mass_g(items) <= u64::from(resolved.cargo_capacity_g()),
                 "ship {} overfills its hold",
                 ship.id
             );
+            let side = spawn.allegiance.expect("a generated ship states its side");
             let credits = match ship.condition {
                 SectorShipConditionType::Intact => {
                     intact += 1;
-                    let crew = ship.crew.as_ref().expect("an intact ship has a crew");
+                    let SpaceshipController::AI(crew) = &spawn.controller else {
+                        panic!("intact ship {} is not AI-flown", ship.id);
+                    };
+                    assert!(!spawn.lootable, "intact ship {} is lootable", ship.id);
+                    assert_eq!(crew.leash, Some(Meters(5_000.0)), "ship {} leash", ship.id);
+                    assert_eq!(
+                        crew.engage_delay,
+                        (side == Allegiance::Enemy).then_some(8.0),
+                        "ship {} arrival grace: only an Enemy crew holds fire",
+                        ship.id
+                    );
                     patrolling += usize::from(!crew.patrol.is_empty());
-                    for (waypoint, stop) in crew.patrol.iter().zip(&crew.stops) {
+                    assert_eq!(
+                        crew.patrol.len(),
+                        crew.patrol_stops.len(),
+                        "ship {} stops",
+                        ship.id
+                    );
+                    for (waypoint, stop) in crew.patrol.iter().zip(&crew.patrol_stops) {
                         let reach = (waypoint.get() - ship.position.get()).length();
                         assert!(
                             (reach - 1_500.0).abs() < 1.0,
@@ -192,6 +210,18 @@ fn every_planned_ship_carries_goods_its_hold_fits_and_credits() {
                 }
                 SectorShipConditionType::Derelict => {
                     wrecks += 1;
+                    assert!(
+                        matches!(spawn.controller, SpaceshipController::None),
+                        "derelict {} is flown",
+                        ship.id
+                    );
+                    assert_eq!(
+                        side,
+                        Allegiance::Neutral,
+                        "derelict {} takes a side",
+                        ship.id
+                    );
+                    assert!(spawn.lootable, "derelict {} is not lootable", ship.id);
                     plateless_wrecks += usize::from(
                         !stacks
                             .iter()
@@ -201,10 +231,10 @@ fn every_planned_ship_carries_goods_its_hold_fits_and_credits() {
                 }
             };
             assert!(
-                credits.contains(&ship.credits),
+                credits.contains(&spawn.credits),
                 "ship {} holds {} credits",
                 ship.id,
-                ship.credits
+                spawn.credits
             );
         }
     }
@@ -287,9 +317,9 @@ fn a_patrol_needs_two_fitting_waypoints() {
 /// the 125 cells around the origin, walked in `desired_sectors` order, for
 /// seed 20,260,922 at a 32 km edge. Every input is written out here rather
 /// than read from the session constants, so retuning New Game cannot move the
-/// recorded window. A deliberate change to the generator's output changes
-/// this digest; record the new value from the left side of this test's
-/// failure message.
+/// recorded window. A deliberate change to the generator's output, or to the
+/// canonical text itself, changes this digest; record the new value from the
+/// left side of this test's failure message and say which of the two moved.
 #[test]
 fn a_pinned_window_generates_the_recorded_bodies() {
     let config = WorldConfig {
@@ -308,7 +338,7 @@ fn a_pinned_window_generates_the_recorded_bodies() {
         .collect();
     assert_eq!(
         Fnv64::new().write(canonical.as_bytes()).finish(),
-        0x81e9_2080_3e38_ff6b,
+        0x67a2_6108_0bd0_8a05,
         "the pinned window's bodies changed"
     );
 }
diff --git a/examples/playable/world_features.rs b/examples/playable/world_features.rs
index 2da12d7e4..e0d52ba15 100644
--- a/examples/playable/world_features.rs
+++ b/examples/playable/world_features.rs
@@ -848,7 +848,7 @@ fn search_ship_shots(world: &mut World) {
                 for ship in manifest.ships {
                     planned += 1;
                     assert_eq!(
-                        ship.design.presentation.style.as_deref(),
+                        ship.design().presentation.style.as_deref(),
                         Some(role_style_id(ship.role)),
                         "world features: ship {} is planned in another style",
                         ship.id
diff --git a/examples/playable/world_sector_inspector.rs b/examples/playable/world_sector_inspector.rs
index 960e8ccb5..95a751506 100644
--- a/examples/playable/world_sector_inspector.rs
+++ b/examples/playable/world_sector_inspector.rs
@@ -441,7 +441,6 @@ fn leave_orbit(keys: Res>, mut inspector: ResMut
 fn collect_sector(
     mut commands: Commands,
     mut inspector: ResMut,
-    game_assets: Res,
     sections: Res,
     world: Res,
     cameras: Query>,
@@ -473,7 +472,7 @@ fn collect_sector(
         .iter()
         .map(|body| InspectedObject {
             id: body.id.clone(),
-            clearance: Meters(body.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
+            clearance: Meters(body.config.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
         })
         .chain(description.planets().iter().map(|planet| InspectedObject {
             id: planet.id.clone(),
@@ -490,8 +489,7 @@ fn collect_sector(
         debug!("inspector: retiring {old}");
         commands.entity(root).despawn();
     }
-    let texture: AssetRef = game_assets.asteroid_texture.clone().into();
-    let root = materialize_sector(&mut commands, prepared, &texture, §ions, observer);
+    let root = materialize_sector(&mut commands, prepared, §ions, observer);
     info!(
         "inspector: live {coord} of seed {}: {rocks} rocks, {planets} planetoids, {ships} ships",
         world.0.seed
diff --git a/examples/screenshots/loop_world_start.rs b/examples/screenshots/loop_world_start.rs
index 73d92e097..636fcd123 100644
--- a/examples/screenshots/loop_world_start.rs
+++ b/examples/screenshots/loop_world_start.rs
@@ -451,7 +451,7 @@ fn predict_arrival_body(
             (
                 rock.id.clone(),
                 rock.position,
-                Meters(rock.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
+                Meters(rock.config.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
             )
         });
         let planets = sector
diff --git a/examples/shared/world_fixture/clustered.rs b/examples/shared/world_fixture/clustered.rs
index e2e255130..a96e6d472 100644
--- a/examples/shared/world_fixture/clustered.rs
+++ b/examples/shared/world_fixture/clustered.rs
@@ -452,12 +452,17 @@ impl CellPlan {
             let position = planned.position;
             match &planned.body {
                 ClusterBody::Rock { radius, kind } => manifest.asteroids.push(SectorAsteroid {
-                    seed: asteroid_seed_from_id(&id),
+                    config: AsteroidConfig {
+                        radius: *radius,
+                        initial_velocity: MetersPerSecond3::ZERO,
+                        texture: AssetRef::Path(ASTEROID_TEXTURE_PATH.to_string()),
+                        kind: (*kind).into(),
+                        destroy_sound: None,
+                        lock_signature: None,
+                        seed: Some(asteroid_seed_from_id(&id)),
+                    },
                     id,
                     position,
-                    radius: *radius,
-                    kind: (*kind).into(),
-                    initial_velocity: MetersPerSecond3::ZERO,
                 }),
                 ClusterBody::Planetoid(config) => manifest.planets.push(SectorPlanet {
                     id,
@@ -468,15 +473,11 @@ impl CellPlan {
                     id,
                     position,
                     rotation: ship.rotation,
-                    initial_velocity: MetersPerSecond3::ZERO,
                     clearance: ship.clearance,
-                    design: ship.design.clone(),
                     condition: ship.condition,
                     civilization: ship.civilization,
                     role: ship.role,
-                    stock: ship.stock.clone(),
-                    credits: ship.credits,
-                    crew: ship.crew.clone(),
+                    spawn: ship.spawn.clone(),
                 }),
                 ClusterBody::Hull { .. } => {
                     unreachable!("a cell lays out every hull it owns before it resolves them")
diff --git a/examples/shared/world_fixture/uniform_asteroids.rs b/examples/shared/world_fixture/uniform_asteroids.rs
index 76521af24..b75ed9668 100644
--- a/examples/shared/world_fixture/uniform_asteroids.rs
+++ b/examples/shared/world_fixture/uniform_asteroids.rs
@@ -117,7 +117,7 @@ impl SectorGenerator for UniformAsteroids {
                 .find(|candidate| {
                     asteroids.iter().all(|rock| {
                         let rock_clearance =
-                            Meters(rock.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX);
+                            Meters(rock.config.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX);
                         bodies_clear(
                             rock.position,
                             rock_clearance,
@@ -136,12 +136,17 @@ impl SectorGenerator for UniformAsteroids {
             let kind =
                 self.asteroid_kinds[stream.next_u32() as usize % self.asteroid_kinds.len()].clone();
             asteroids.push(SectorAsteroid {
-                seed: asteroid_seed_from_id(&id),
+                config: AsteroidConfig {
+                    radius,
+                    initial_velocity: MetersPerSecond3::ZERO,
+                    texture: AssetRef::Path(ASTEROID_TEXTURE_PATH.to_string()),
+                    kind,
+                    destroy_sound: None,
+                    lock_signature: None,
+                    seed: Some(asteroid_seed_from_id(&id)),
+                },
                 id,
                 position,
-                radius,
-                kind,
-                initial_velocity: MetersPerSecond3::ZERO,
             });
         }
         Ok(SectorManifest {
diff --git a/examples/systems/system_world_sectors.rs b/examples/systems/system_world_sectors.rs
index 4f1d45b68..c8c3e1302 100644
--- a/examples/systems/system_world_sectors.rs
+++ b/examples/systems/system_world_sectors.rs
@@ -1040,7 +1040,7 @@ fn report_clearance(world: &mut World) {
                     body.position,
                     // A rock's meshed surface reaches up to this multiple past
                     // its nominal radius, so that is what has to clear.
-                    Meters(body.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
+                    Meters(body.config.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
                 )
             })
             .chain(description.planets().iter().map(|planet| {

From d9c234403db6b17e1af5189acc4bbba01b4b2eb9 Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 11:44:00 +0300
Subject: [PATCH 08/22] Type sector object ids

Claim: a sector object id could be any string, so validate_manifest had
to check each one started with its cell's slug at runtime.

Change: `SectorObjectId(String)` with a private field, built only by
`sector_id(coord, name, index)`, with Display and
`From for EntityId`. The prefix invariant moves into the
constructor and the `starts_with` check in validate_manifest is deleted;
the duplicate-id check stays. SectorAsteroid, SectorPlanet, SectorShip,
clusters.rs Candidate/PlannedBody and the example world fixtures carry
the typed id; SectorFault keeps `id: String` via Display, and
PendingShip keeps `id: String` because a frozen body may be any live
entity. Tests build ids with `sector_id` instead of literals.

Verification: cargo fmt; cargo check -p nova_world -p nova_world_base
-p nova_scenario -p nova_menu -p nova_interface --all-targets; cargo
check --examples --keep-going; cargo test -p nova_world --lib (39 ok);
cargo test -p nova_world_base --lib (91 ok); cargo test -p nova_scenario
--lib spaceship (20 ok).

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_world/src/frozen.rs               |  4 +-
 crates/nova_world/src/generation.rs           | 79 ++++++++++++-------
 crates/nova_world/src/lib.rs                  |  8 +-
 crates/nova_world/src/streaming.rs            | 12 +--
 crates/nova_world/src/tests.rs                | 48 +++++------
 crates/nova_world/src/tests/frozen.rs         | 56 +++++++------
 crates/nova_world_base/src/clusters.rs        | 73 +++++++++++------
 examples/playable/world_sector_inspector.rs   |  6 +-
 examples/shared/world_fixture/clustered.rs    | 20 +++--
 .../shared/world_fixture/uniform_asteroids.rs |  4 +-
 10 files changed, 179 insertions(+), 131 deletions(-)

diff --git a/crates/nova_world/src/frozen.rs b/crates/nova_world/src/frozen.rs
index 990b1bca0..f81c4e352 100644
--- a/crates/nova_world/src/frozen.rs
+++ b/crates/nova_world/src/frozen.rs
@@ -322,7 +322,7 @@ impl PreparedFrozenBody {
     pub(crate) fn prepare(coord: SectorCoord, body: FrozenBody, ships: &[SectorShip]) -> Self {
         match &body.body {
             FrozenBodyType::PendingShip { id } => {
-                let Some(ship) = ships.iter().find(|ship| ship.id == *id) else {
+                let Some(ship) = ships.iter().find(|ship| ship.id.as_str() == id) else {
                     panic!(
                         "nova_world: {coord} froze the held ship '{id}' but regenerates no ship \
                          of that id"
@@ -474,7 +474,7 @@ pub(crate) fn freeze_body(
 ) -> Result {
     let body = world.entity(entity);
     if let Some(held) = body.get::() {
-        let id = held.ship().id.clone();
+        let id = held.ship().id.to_string();
         return Ok(FrozenBody {
             id: Some(EntityId::new(id.clone())),
             name: None,
diff --git a/crates/nova_world/src/generation.rs b/crates/nova_world/src/generation.rs
index 7f00d518d..4fad317bc 100644
--- a/crates/nova_world/src/generation.rs
+++ b/crates/nova_world/src/generation.rs
@@ -11,7 +11,7 @@
 use std::{collections::BTreeSet, fmt, sync::Arc};
 
 use bevy::{log::info_span, math::Quat};
-use nova_events::prelude::{Meters, Meters3};
+use nova_events::prelude::{EntityId, Meters, Meters3};
 use nova_gameplay::prelude::{AssetRef, Fnv32, SeedStream};
 use nova_scenario::prelude::{
     is_asteroid_kind, patrol_stops_fault, prepare_asteroid_geometry, prepare_planet,
@@ -25,11 +25,37 @@ use crate::{
     SectorCoord, SectorFault, SectorGenerationInput, SectorGenerator, WorldConfig,
 };
 
+/// A sector object's scenario id: `__`, built only by
+/// [`sector_id`], so every id names the cell that owns it and a scenario
+/// cannot author one by accident. One cell cannot hold the same id twice;
+/// [`validate_manifest`] refuses the repeat.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct SectorObjectId(String);
+
+impl SectorObjectId {
+    /// The id as the scenario addresses it.
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+}
+
+impl fmt::Display for SectorObjectId {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter.write_str(&self.0)
+    }
+}
+
+impl From for EntityId {
+    fn from(id: SectorObjectId) -> Self {
+        EntityId(id.0)
+    }
+}
+
 /// One generated rock, in world meters.
 #[derive(Clone, Debug)]
 pub struct SectorAsteroid {
-    /// The rock's scenario id, prefixed with its owning cell's slug.
-    pub id: String,
+    /// The rock's scenario id.
+    pub id: SectorObjectId,
     /// Where it stands, in meters from the world origin.
     pub position: Meters3,
     /// The rock as the spawn builds it. `seed` is `Some`: the generator pins
@@ -59,8 +85,8 @@ impl SectorAsteroid {
 /// One generated planetoid: a real [`PlanetConfig`], not a big rock.
 #[derive(Clone, Debug)]
 pub struct SectorPlanet {
-    /// The planetoid's scenario id, prefixed with its owning cell's slug.
-    pub id: String,
+    /// The planetoid's scenario id.
+    pub id: SectorObjectId,
     /// Where it stands, in meters from the world origin.
     pub position: Meters3,
     /// The world it is, ready for `prepare_planet`.
@@ -215,8 +241,8 @@ impl ShipRoleType {
 /// builds it.
 #[derive(Clone, Debug)]
 pub struct SectorShip {
-    /// The ship's scenario id, prefixed with its owning cell's slug.
-    pub id: String,
+    /// The ship's scenario id.
+    pub id: SectorObjectId,
     /// Where its root floats, in meters from the world origin.
     pub position: Meters3,
     /// Its full orientation: a finite unit quaternion.
@@ -314,7 +340,7 @@ impl SectorDescription {
 
     /// Every object id this cell spawns, in spawn order: rocks, then
     /// planetoids, then ships.
-    pub fn object_ids(&self) -> Vec {
+    pub fn object_ids(&self) -> Vec {
         self.asteroids
             .iter()
             .map(|body| body.id.clone())
@@ -563,8 +589,8 @@ fn position_is_finite(position: Meters3) -> bool {
 /// Formatting only. The slug prefix is the cross-generator contract:
 /// [`validate_manifest`] refuses an id without it, so no two cells can claim
 /// one object, and refuses an id claimed twice inside the cell.
-pub fn sector_id(coord: SectorCoord, name: &str, index: usize) -> String {
-    format!("{}_{name}_{index}", coord.slug())
+pub fn sector_id(coord: SectorCoord, name: &str, index: usize) -> SectorObjectId {
+    SectorObjectId(format!("{}_{name}_{index}", coord.slug()))
 }
 
 /// Turn a generator's manifest into a trusted [`SectorDescription`], or refuse
@@ -620,31 +646,24 @@ pub fn validate_manifest(
     if !position_is_finite(centre) {
         return Err(SectorFault::InvalidGeometry { id: coord.slug() });
     }
-    let refuse = |id: &str, field: &'static str, value: String| SectorFault::Manifest {
+    let refuse = |id: &SectorObjectId, field: &'static str, value: String| SectorFault::Manifest {
         id: id.to_string(),
         field,
         value,
     };
 
     if manifest.coord != coord {
-        return Err(refuse(
-            &coord.slug(),
-            "coord",
-            format!("{}, not the requested {coord}", manifest.coord),
-        ));
+        return Err(SectorFault::Manifest {
+            id: coord.slug(),
+            field: "coord",
+            value: format!("{}, not the requested {coord}", manifest.coord),
+        });
     }
 
     let mut ids = BTreeSet::new();
     let mut standing = Vec::new();
-    let mut own = |id: &str| {
-        if !id.starts_with(&format!("{}_", coord.slug())) {
-            return Err(refuse(
-                id,
-                "id",
-                format!("'{id}', not prefixed with {}", coord.slug()),
-            ));
-        }
-        if !ids.insert(id.to_string()) {
+    let mut own = |id: &SectorObjectId| {
+        if !ids.insert(id.clone()) {
             return Err(SectorFault::DuplicateId { id: id.to_string() });
         }
         Ok(())
@@ -655,7 +674,7 @@ pub fn validate_manifest(
         let config = &body.config;
         if !config.radius.get().is_finite() || config.radius.get() <= 0.0 {
             return Err(SectorFault::InvalidGeometry {
-                id: body.id.clone(),
+                id: body.id.to_string(),
             });
         }
         if !is_asteroid_kind(&config.kind) {
@@ -696,7 +715,7 @@ pub fn validate_manifest(
         let clearance = planet.config.body_radius();
         if !clearance.get().is_finite() || clearance.get() <= 0.0 {
             return Err(SectorFault::InvalidGeometry {
-                id: planet.id.clone(),
+                id: planet.id.to_string(),
             });
         }
         stand_inside(input, &mut standing, &planet.id, planet.position, clearance)?;
@@ -715,13 +734,13 @@ pub fn validate_manifest(
             || (ship.rotation.length() - 1.0).abs() > ROTATION_LENGTH_TOLERANCE
         {
             return Err(SectorFault::InvalidGeometry {
-                id: ship.id.clone(),
+                id: ship.id.to_string(),
             });
         }
         let clearance = ship.clearance.get();
         if !clearance.is_finite() || clearance <= 0.0 {
             return Err(SectorFault::InvalidGeometry {
-                id: ship.id.clone(),
+                id: ship.id.to_string(),
             });
         }
         if ship.clearance > SECTOR_SHIP_CLEARANCE_MAX {
@@ -851,7 +870,7 @@ fn check_ship_design(design: &ShipDesign) -> Result<(), String> {
 fn stand_inside(
     input: SectorGenerationInput,
     standing: &mut Vec,
-    id: &str,
+    id: &SectorObjectId,
     position: Meters3,
     clearance: Meters,
 ) -> Result<(), SectorFault> {
diff --git a/crates/nova_world/src/lib.rs b/crates/nova_world/src/lib.rs
index ac8496185..422d59e64 100644
--- a/crates/nova_world/src/lib.rs
+++ b/crates/nova_world/src/lib.rs
@@ -192,7 +192,8 @@ pub use crate::{
     generation::{
         bodies_clear, generate_sector, prepare_sector, sector_id, validate_manifest,
         CivilizationId, PreparedSector, SectorAsteroid, SectorDescription, SectorManifest,
-        SectorPlanet, SectorShip, SectorShipConditionType, ShipRoleType, SECTOR_SHIP_CLEARANCE_MAX,
+        SectorObjectId, SectorPlanet, SectorShip, SectorShipConditionType, ShipRoleType,
+        SECTOR_SHIP_CLEARANCE_MAX,
     },
     streaming::{
         clear_sector_work, collect_sector_jobs, desired_sectors, live_sectors,
@@ -210,8 +211,9 @@ pub mod prelude {
         bodies_clear, generate_sector, prepare_sector, sector_id, validate_manifest,
         CivilizationId, NovaWorldPlugin, NovaWorldSystems, PreparedSector, SectorAsteroid,
         SectorCoord, SectorDescription, SectorFault, SectorGenerationInput, SectorGenerator,
-        SectorManifest, SectorPlanet, SectorShip, SectorShipConditionType, ShipRoleType,
-        WorldConfig, WorldGeometry, ACTIVE_WINDOW_SECTORS_MAX, SECTOR_SHIP_CLEARANCE_MAX,
+        SectorManifest, SectorObjectId, SectorPlanet, SectorShip, SectorShipConditionType,
+        ShipRoleType, WorldConfig, WorldGeometry, ACTIVE_WINDOW_SECTORS_MAX,
+        SECTOR_SHIP_CLEARANCE_MAX,
     };
     pub use crate::{
         frozen::{
diff --git a/crates/nova_world/src/streaming.rs b/crates/nova_world/src/streaming.rs
index 1b3ae897a..17e90e6bd 100644
--- a/crates/nova_world/src/streaming.rs
+++ b/crates/nova_world/src/streaming.rs
@@ -236,8 +236,8 @@ pub fn materialize_sector(
     for PreparedRock { body, geometry } in rocks {
         let mut entity = commands.spawn((
             base_scenario_object(&BaseScenarioObjectConfig {
-                id: body.id.clone(),
-                name: body.id.clone(),
+                id: body.id.to_string(),
+                name: body.id.to_string(),
                 position: body.position,
                 rotation: Quat::IDENTITY,
             }),
@@ -250,8 +250,8 @@ pub fn materialize_sector(
     for PreparedPlanetoid { body, surface } in planets {
         let mut entity = commands.spawn((
             base_scenario_object(&BaseScenarioObjectConfig {
-                id: body.id.clone(),
-                name: body.id.clone(),
+                id: body.id.to_string(),
+                name: body.id.to_string(),
                 position: body.position,
                 rotation: Quat::IDENTITY,
             }),
@@ -299,7 +299,7 @@ fn require_resolved(ship: &SectorShip, sections: &GameSections) {
         panic!(
             "nova_world: {}",
             SectorFault::InvalidShipDesign {
-                id: ship.id.clone(),
+                id: ship.id.to_string(),
                 errors,
             }
         );
@@ -330,7 +330,7 @@ fn spawn_sector_ship(commands: &mut Commands, root: Entity, ship: SectorShip) {
     };
     let ship = (
         base_scenario_object(&BaseScenarioObjectConfig {
-            id,
+            id: id.to_string(),
             name,
             position,
             rotation,
diff --git a/crates/nova_world/src/tests.rs b/crates/nova_world/src/tests.rs
index 3fcad6279..994e3db44 100644
--- a/crates/nova_world/src/tests.rs
+++ b/crates/nova_world/src/tests.rs
@@ -44,8 +44,9 @@ use crate::{
     generate_sector, materialize_pending_ships, materialize_sector, prepare_sector, sector_id,
     validate_manifest, CivilizationId, NovaWorldPlugin, ObserverBody, PendingSectorShip,
     SectorAsteroid, SectorCoord, SectorFault, SectorGenerationInput, SectorGenerator,
-    SectorManifest, SectorPlanet, SectorShip, SectorShipConditionType, ShipRoleType, WorldConfig,
-    WorldGeometry, WorldObserver, ACTIVE_WINDOW_SECTORS_MAX, SECTOR_SHIP_CLEARANCE_MAX,
+    SectorManifest, SectorObjectId, SectorPlanet, SectorShip, SectorShipConditionType,
+    ShipRoleType, WorldConfig, WorldGeometry, WorldObserver, ACTIVE_WINDOW_SECTORS_MAX,
+    SECTOR_SHIP_CLEARANCE_MAX,
 };
 
 /// The texture path every test rock names. Never loaded: no test here renders.
@@ -149,7 +150,7 @@ fn empty(coord: SectorCoord, asteroids: Vec) -> SectorManifest {
 /// A one-section intact ship at `position`, built from the section prototype
 /// `prototype`, with a 20 m clearance, flown by a routeless Neutral AI crew
 /// on a 5 km leash, with an empty hold and 120 credits.
-fn ship(id: String, position: Meters3, prototype: &str) -> SectorShip {
+fn ship(id: SectorObjectId, position: Meters3, prototype: &str) -> SectorShip {
     SectorShip {
         id,
         position,
@@ -253,9 +254,9 @@ fn a_valid_config_describes_a_sector() {
 /// the lint.
 #[test]
 fn a_malformed_generator_answer_is_refused_before_preparation() {
-    fn rock(input: SectorGenerationInput, id: &str, offset: f32) -> SectorAsteroid {
+    fn rock(input: SectorGenerationInput, index: usize, offset: f32) -> SectorAsteroid {
         SectorAsteroid {
-            id: format!("{}_{id}", input.coord.slug()),
+            id: sector_id(input.coord, "body", index),
             position: input.coord.centre(input.geometry.sector_edge)
                 + Meters3::new(offset, 0.0, 0.0),
             config: rock_config(Meters(40.0), 7),
@@ -265,34 +266,25 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
         &str,
         fn(SectorGenerationInput) -> SectorManifest,
         fn(&SectorFault) -> bool,
-    ); 23] = [
+    ); 22] = [
         (
             "the wrong cell",
             |input| empty(input.coord.offset(1, 0, 0), Vec::new()),
             |fault| matches!(fault, SectorFault::Manifest { field: "coord", .. }),
         ),
-        (
-            "an id another cell owns",
-            |input| {
-                let mut body = rock(input, "body_0", 0.0);
-                body.id = "sector_1_0_0_body_0".to_string();
-                empty(input.coord, vec![body])
-            },
-            |fault| matches!(fault, SectorFault::Manifest { field: "id", .. }),
-        ),
         (
             "one id twice",
             |input| {
                 empty(
                     input.coord,
-                    vec![rock(input, "body_0", 0.0), rock(input, "body_0", 9_000.0)],
+                    vec![rock(input, 0, 0.0), rock(input, 0, 9_000.0)],
                 )
             },
             |fault| matches!(fault, SectorFault::DuplicateId { .. }),
         ),
         (
             "a rock across a face",
-            |input| empty(input.coord, vec![rock(input, "body_0", 15_900.0)]),
+            |input| empty(input.coord, vec![rock(input, 0, 15_900.0)]),
             |fault| {
                 matches!(
                     fault,
@@ -308,7 +300,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
             |input| {
                 empty(
                     input.coord,
-                    vec![rock(input, "body_0", 0.0), rock(input, "body_1", 100.0)],
+                    vec![rock(input, 0, 0.0), rock(input, 1, 100.0)],
                 )
             },
             |fault| {
@@ -324,7 +316,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
         (
             "an asteroid kind the game does not ship",
             |input| {
-                let mut body = rock(input, "body_0", 0.0);
+                let mut body = rock(input, 0, 0.0);
                 body.config.kind = "obsidian".into();
                 empty(input.coord, vec![body])
             },
@@ -333,7 +325,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
         (
             "an asteroid with a non-finite initial velocity",
             |input| {
-                let mut body = rock(input, "body_0", 0.0);
+                let mut body = rock(input, 0, 0.0);
                 body.config.initial_velocity = MetersPerSecond3::new(f32::NAN, 0.0, 0.0);
                 empty(input.coord, vec![body])
             },
@@ -350,7 +342,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
         (
             "an asteroid that pins no silhouette seed",
             |input| {
-                let mut body = rock(input, "body_0", 0.0);
+                let mut body = rock(input, 0, 0.0);
                 body.config.seed = None;
                 empty(input.coord, vec![body])
             },
@@ -359,7 +351,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
         (
             "an asteroid whose texture is a runtime handle",
             |input| {
-                let mut body = rock(input, "body_0", 0.0);
+                let mut body = rock(input, 0, 0.0);
                 body.config.texture = AssetRef::Handle(Handle::default());
                 empty(input.coord, vec![body])
             },
@@ -396,7 +388,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
             |input| {
                 let mut manifest = empty(input.coord, Vec::new());
                 manifest.planets.push(SectorPlanet {
-                    id: format!("{}_planet_0", input.coord.slug()),
+                    id: sector_id(input.coord, "planet", 0),
                     position: input.coord.centre(input.geometry.sector_edge),
                     config: PlanetConfig::new(PlanetType::BarrenRock, Meters(800.0), 3, f32::NAN),
                 });
@@ -407,7 +399,7 @@ fn a_malformed_generator_answer_is_refused_before_preparation() {
         (
             "a rock at a non-finite position",
             |input| {
-                let mut body = rock(input, "body_0", 0.0);
+                let mut body = rock(input, 0, 0.0);
                 body.position = Meters3::new(f32::NAN, 0.0, 0.0);
                 empty(input.coord, vec![body])
             },
@@ -825,7 +817,7 @@ fn a_materialized_asteroid_opts_into_gravity() {
     assert_eq!(
         materialized,
         vec![(
-            sector_id(SectorCoord::ORIGIN, "body", 0),
+            sector_id(SectorCoord::ORIGIN, "body", 0).to_string(),
             RigidBody::Dynamic,
             Vec3::new(2.4, -0.8, 0.4),
             root,
@@ -967,7 +959,7 @@ fn ships_under(world: &mut World, root: Entity) -> (Vec, Vec) {
         .query::<(&PendingSectorShip, &ChildOf)>()
         .iter(world)
         .filter(|(_, child_of)| child_of.parent() == root)
-        .map(|(held, _)| held.ship().id.clone())
+        .map(|(held, _)| held.ship().id.to_string())
         .collect();
     (spawned, held)
 }
@@ -983,7 +975,7 @@ fn a_ship_the_observer_overlaps_is_held_until_the_observer_is_clear() {
     let id = sector_id(SectorCoord::ORIGIN, "ship", 0);
     assert_eq!(
         ships_under(&mut world, root),
-        (Vec::new(), vec![id.clone()]),
+        (Vec::new(), vec![id.to_string()]),
         "an overlapped ship must be held, not spawned"
     );
 
@@ -1001,7 +993,7 @@ fn a_ship_the_observer_overlaps_is_held_until_the_observer_is_clear() {
     world.flush();
     assert_eq!(
         ships_under(&mut world, root),
-        (Vec::new(), vec![id.clone()]),
+        (Vec::new(), vec![id.to_string()]),
         "a ship must stay held while the observer overlaps it"
     );
 
diff --git a/crates/nova_world/src/tests/frozen.rs b/crates/nova_world/src/tests/frozen.rs
index 658c11b1b..bb505927e 100644
--- a/crates/nova_world/src/tests/frozen.rs
+++ b/crates/nova_world/src/tests/frozen.rs
@@ -40,7 +40,7 @@ use crate::{
     materialize_sector, retire_sectors, sector_id, streaming::ClearedConfig, CurrentSector,
     FrozenBodyType, FrozenSector, FrozenSectors, ObserverBody, PendingSectorShip, ReadySectors,
     SectorAsteroid, SectorCoord, SectorGenerationInput, SectorGenerator, SectorJobStats,
-    SectorManifest, SectorRoot, WorldConfig, WorldObserver,
+    SectorManifest, SectorObjectId, SectorRoot, WorldConfig, WorldObserver,
 };
 
 /// Arm `config` around `centre` with nothing live yet, so the arming frame's
@@ -461,7 +461,7 @@ fn a_rock_that_leaves_the_window_arrives_in_its_ungenerated_cell_beside_its_gene
     let landing = far_cell.centre(edge) + Meters3::new(-quarter, 0.0, quarter);
     let wanderer = sector_id(home, "body", 0);
     let world = app.world_mut();
-    let [rock] = body_with_id(world, &wanderer)[..] else {
+    let [rock] = body_with_id(world, wanderer.as_str())[..] else {
         panic!("home must spawn its first rock");
     };
     assert_eq!(
@@ -501,7 +501,7 @@ fn a_rock_that_leaves_the_window_arrives_in_its_ungenerated_cell_beside_its_gene
         ),
         "an ungenerated cell keeps the rock as an arrival"
     );
-    assert_eq!(frozen_ids(world, far_cell), vec![wanderer.clone()]);
+    assert_eq!(frozen_ids(world, far_cell), vec![wanderer.to_string()]);
 
     walk_to::(world, far_cell);
     let root = materialize::(world, far_cell, far());
@@ -516,16 +516,22 @@ fn a_rock_that_leaves_the_window_arrives_in_its_ungenerated_cell_beside_its_gene
         .collect();
     under.sort_by(|a, b| a.0.cmp(&b.0));
     let mut expected: Vec = (0..4)
-        .map(|index| sector_id(far_cell, "body", index))
+        .map(|index| sector_id(far_cell, "body", index).to_string())
         .collect();
-    expected.push(wanderer.clone());
+    expected.push(wanderer.to_string());
     expected.sort();
     assert_eq!(
         under.iter().map(|(id, _)| id.clone()).collect::>(),
         expected,
         "the generated rocks and the arrival all stand in the cell"
     );
-    let at = |id: &str| under.iter().find(|(each, _)| each == id).unwrap().1;
+    let at = |id: &SectorObjectId| {
+        under
+            .iter()
+            .find(|(each, _)| each == id.as_str())
+            .unwrap()
+            .1
+    };
     assert_eq!(
         at(&wanderer),
         landing.to_engine(),
@@ -555,7 +561,7 @@ fn ending_the_session_forgets_every_frozen_cell() {
     app.update();
 
     let world = app.world_mut();
-    let [rock] = body_with_id(world, §or_id(home, "body", 0))[..] else {
+    let [rock] = body_with_id(world, sector_id(home, "body", 0).as_str())[..] else {
         panic!("home must spawn its first rock");
     };
     world.entity_mut(rock).despawn();
@@ -712,10 +718,10 @@ fn wreck_home(app: &mut App) -> (Entity, Entity) {
     settle(app);
 
     let world = app.world_mut();
-    let [rock] = body_with_id(world, §or_id(home, "body", 0))[..] else {
+    let [rock] = body_with_id(world, sector_id(home, "body", 0).as_str())[..] else {
         panic!("home must spawn its rock");
     };
-    let [fought] = body_with_id(world, §or_id(home, "ship", 0))[..] else {
+    let [fought] = body_with_id(world, sector_id(home, "ship", 0).as_str())[..] else {
         panic!("home must spawn the ship to fight");
     };
     // Mined: one crater, carried through the carve chain to a validated
@@ -816,10 +822,10 @@ fn a_mined_fought_and_looted_cell_returns_as_it_was_left() {
     settle(&mut app);
 
     let world = app.world_mut();
-    let [rock] = body_with_id(world, §or_id(home, "body", 0))[..] else {
+    let [rock] = body_with_id(world, sector_id(home, "body", 0).as_str())[..] else {
         panic!("the rock must come back once");
     };
-    let [fought] = body_with_id(world, §or_id(home, "ship", 0))[..] else {
+    let [fought] = body_with_id(world, sector_id(home, "ship", 0).as_str())[..] else {
         panic!("the fought ship must come back once");
     };
     assert_eq!(world.get::(rock).map(ChildOf::parent), Some(root));
@@ -872,9 +878,9 @@ fn a_mined_fought_and_looted_cell_returns_as_it_was_left() {
     let held: Vec = world
         .query::<&PendingSectorShip>()
         .iter(world)
-        .map(|held| held.ship().id.clone())
+        .map(|held| held.ship().id.to_string())
         .collect();
-    assert_eq!(held, vec![sector_id(home, "ship", 1)]);
+    assert_eq!(held, vec![sector_id(home, "ship", 1).to_string()]);
     assert_eq!(
         world
             .query_filtered::<(), With>()
@@ -956,10 +962,10 @@ fn a_snapshot_of_the_live_world_reads_back_as_the_same_world() {
     materialize::(world, home, far());
     settle(&mut fresh);
     let world = fresh.world_mut();
-    let [rock] = body_with_id(world, §or_id(home, "body", 0))[..] else {
+    let [rock] = body_with_id(world, sector_id(home, "body", 0).as_str())[..] else {
         panic!("the rock must come back once");
     };
-    let [fought] = body_with_id(world, §or_id(home, "ship", 0))[..] else {
+    let [fought] = body_with_id(world, sector_id(home, "ship", 0).as_str())[..] else {
         panic!("the fought ship must come back once");
     };
     let node = rock_node(world, rock);
@@ -978,9 +984,9 @@ fn a_snapshot_of_the_live_world_reads_back_as_the_same_world() {
     let held: Vec = world
         .query::<&PendingSectorShip>()
         .iter(world)
-        .map(|held| held.ship().id.clone())
+        .map(|held| held.ship().id.to_string())
         .collect();
-    assert_eq!(held, vec![sector_id(home, "ship", 1)]);
+    assert_eq!(held, vec![sector_id(home, "ship", 1).to_string()]);
 }
 
 /// A docked pair is undocked ON PAPER before it freezes: `snapshot_sectors`
@@ -1234,14 +1240,16 @@ fn settled_rocks_freeze_while_an_unsettled_sibling_holds_its_cell() {
 
     let world = app.world_mut();
     let unsettled_id = sector_id(home, "body", 0);
-    let [unsettled] = body_with_id(world, &unsettled_id)[..] else {
+    let [unsettled] = body_with_id(world, unsettled_id.as_str())[..] else {
         panic!("home must spawn its first rock");
     };
     let node = rock_node(world, unsettled);
     world.entity_mut(node).insert(IntegrityDestroyMarker);
 
     walk_to::(world, home.offset(5, 0, 0));
-    let settled: Vec = (1..4).map(|index| sector_id(home, "body", index)).collect();
+    let settled: Vec = (1..4)
+        .map(|index| sector_id(home, "body", index).to_string())
+        .collect();
     assert_eq!(frozen_ids(world, home), settled, "the settled rocks froze");
     assert_eq!(
         world.get::(unsettled).map(ChildOf::parent),
@@ -1284,7 +1292,7 @@ fn an_unsettled_rock_retires_its_cell_once_it_settles() {
     app.update();
 
     let world = app.world_mut();
-    let [unsettled] = body_with_id(world, §or_id(home, "body", 0))[..] else {
+    let [unsettled] = body_with_id(world, sector_id(home, "body", 0).as_str())[..] else {
         panic!("home must spawn its first rock");
     };
     let node = rock_node(world, unsettled);
@@ -1302,7 +1310,9 @@ fn an_unsettled_rock_retires_its_cell_once_it_settles() {
     assert!(world.get_entity(root).is_err(), "the cell retired");
     let mut ids = frozen_ids(world, home);
     ids.sort();
-    let expected: Vec = (0..4).map(|index| sector_id(home, "body", index)).collect();
+    let expected: Vec = (0..4)
+        .map(|index| sector_id(home, "body", index).to_string())
+        .collect();
     assert_eq!(ids, expected, "one record holds every rock");
 
     walk_to::(world, home);
@@ -1335,7 +1345,7 @@ fn a_rock_that_never_settles_fails_at_the_bound() {
     app.update();
 
     let world = app.world_mut();
-    let [unsettled] = body_with_id(world, §or_id(home, "body", 0))[..] else {
+    let [unsettled] = body_with_id(world, sector_id(home, "body", 0).as_str())[..] else {
         panic!("home must spawn its first rock");
     };
     let node = rock_node(world, unsettled);
@@ -1395,7 +1405,7 @@ fn replacing_the_world_takes_the_bodies_waiting_top_level() {
 
     let world = app.world_mut();
     let [desired, unsettled, authored] = [0, 1, 2].map(|index| {
-        let [rock] = body_with_id(world, §or_id(home, "body", index))[..] else {
+        let [rock] = body_with_id(world, sector_id(home, "body", index).as_str())[..] else {
             panic!("home must spawn rock {index}");
         };
         rock
diff --git a/crates/nova_world_base/src/clusters.rs b/crates/nova_world_base/src/clusters.rs
index 2f0e5d8cb..b56e2cad3 100644
--- a/crates/nova_world_base/src/clusters.rs
+++ b/crates/nova_world_base/src/clusters.rs
@@ -591,7 +591,7 @@ impl SkipType {
 /// One body a cell owns, and what the cell did with it.
 #[derive(Clone, Debug)]
 struct PlannedBody {
-    id: String,
+    id: SectorObjectId,
     source: BodySource,
     body: ClusterBody,
     position: Meters3,
@@ -761,7 +761,7 @@ impl SectorPlan {
                 .find(|cluster| body.source.node() == Some(cluster.node))
             else {
                 return Err(SectorFault::Generation {
-                    id: body.id.clone(),
+                    id: body.id.to_string(),
                     field: "placement",
                     value: format!(
                         "seed {seed} sector {} has no owning cluster for an overlapped body",
@@ -857,7 +857,7 @@ impl SectorPlan {
                 Fnv32::new()
                     .write(&seed.to_le_bytes())
                     .write(b"overlap-placement")
-                    .write(body.id.as_bytes())
+                    .write(body.id.as_str().as_bytes())
                     .finish(),
             );
             let mut replacement = None;
@@ -911,7 +911,7 @@ impl SectorPlan {
                 }
             }
             let position = replacement.ok_or_else(|| SectorFault::Generation {
-                id: self.bodies[index].id.clone(),
+                id: self.bodies[index].id.to_string(),
                 field: "placement",
                 value: format!("seed {seed} sector {} has no unique-well safe-band or well-free position after deterministic retries", self.coord),
             })?;
@@ -962,7 +962,7 @@ impl SectorPlan {
                     config: AsteroidConfig {
                         radius: *radius,
                         initial_velocity: generated_initial_velocity(
-                            &planned.id,
+                            planned.id.as_str(),
                             position,
                             Meters(radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
                             self.coord,
@@ -974,7 +974,7 @@ impl SectorPlan {
                         kind: (*kind).into(),
                         destroy_sound: None,
                         lock_signature: None,
-                        seed: Some(asteroid_seed_from_id(&id)),
+                        seed: Some(asteroid_seed_from_id(id.as_str())),
                     },
                     id,
                     position,
@@ -994,7 +994,7 @@ impl SectorPlan {
                     role: ship.role,
                     spawn: SpaceshipConfig {
                         initial_velocity: generated_initial_velocity(
-                            &planned.id,
+                            planned.id.as_str(),
                             position,
                             ship.clearance,
                             self.coord,
@@ -1611,7 +1611,7 @@ pub(crate) fn plan_sector(
                 &civilizations,
                 input.seed,
                 HullSlot {
-                    id: &planned.id,
+                    id: planned.id.as_str(),
                     node,
                     slot: index,
                     anchor: cluster.anchor,
@@ -1730,7 +1730,7 @@ fn plan_patrols(input: SectorGenerationInput, bodies: &mut [PlannedBody]) {
 
 /// One body a cell owns, before it is resolved.
 struct Candidate {
-    id: String,
+    id: SectorObjectId,
     source: BodySource,
     body: ClusterBody,
     position: Meters3,
@@ -1798,13 +1798,13 @@ fn resolve(
     } in candidates
     {
         if !position.get().is_finite() {
-            return Err(SectorFault::InvalidGeometry { id });
+            return Err(SectorFault::InvalidGeometry { id: id.to_string() });
         }
         let clearance = body.clearance();
         let mut skipped = fits(input, &standing, position, clearance);
         if let (BodySource::Parent(..), Some(reason)) = (source, skipped) {
             return Err(SectorFault::Generation {
-                id,
+                id: id.to_string(),
                 field: "parent",
                 value: format!("skipped for {}", reason.label()),
             });
@@ -1819,7 +1819,9 @@ fn resolve(
                 skipped = Some(SkipType::Companion);
                 for escort in escorts {
                     if !escort.position.get().is_finite() {
-                        return Err(SectorFault::InvalidGeometry { id: escort.id });
+                        return Err(SectorFault::InvalidGeometry {
+                            id: escort.id.to_string(),
+                        });
                     }
                     let escort_clearance = escort.body.clearance();
                     // `validate` keeps the escort band clear of its hull.
@@ -2121,7 +2123,11 @@ mod tests {
         )
         .expect_err("a parent over a placed body must refuse");
         assert!(
-            matches!(&fault, SectorFault::Generation { id, field: "parent", .. } if id == "parent"),
+            matches!(
+                &fault,
+                SectorFault::Generation { id, field: "parent", .. }
+                    if *id == sector_id(SectorCoord::ORIGIN, "parent", 0).to_string()
+            ),
             "got {fault:?}"
         );
     }
@@ -2129,7 +2135,7 @@ mod tests {
     /// A 40 m rock candidate on the z axis of the origin cell.
     fn rock_at(id: &str, source: BodySource, z: f32) -> Candidate {
         Candidate {
-            id: id.to_string(),
+            id: sector_id(SectorCoord::ORIGIN, id, 0),
             source,
             body: ClusterBody::Rock {
                 radius: Meters(40.0),
@@ -2144,7 +2150,7 @@ mod tests {
     /// given z.
     fn hull_at(node: [i32; 3], z: f32, escorts: &[f32]) -> Candidate {
         Candidate {
-            id: "hull".to_string(),
+            id: sector_id(SectorCoord::ORIGIN, "hull", 0),
             source: BodySource::Hull(node, 0),
             body: ClusterBody::Hull {
                 yaw: 0.0,
@@ -2187,7 +2193,10 @@ mod tests {
                 rock_at("rock", BodySource::Rock(own, 0), 0.0),
                 hull_at(own, 12_000.0, &[13_500.0]),
             ]),
-            [("rock".into(), None), ("hull".into(), None)],
+            [
+                (sector_id(SectorCoord::ORIGIN, "rock", 0), None),
+                (sector_id(SectorCoord::ORIGIN, "hull", 0), None)
+            ],
             "a hull beside its own cluster's rock needs no escort"
         );
         assert_eq!(
@@ -2197,9 +2206,12 @@ mod tests {
                 &[15_900.0, 13_500.0, 10_500.0]
             )]),
             [
-                ("escort_0".into(), Some(SkipType::Face)),
-                ("escort_1".into(), None),
-                ("hull".into(), None)
+                (
+                    sector_id(SectorCoord::ORIGIN, "escort_0", 0),
+                    Some(SkipType::Face)
+                ),
+                (sector_id(SectorCoord::ORIGIN, "escort_1", 0), None),
+                (sector_id(SectorCoord::ORIGIN, "hull", 0), None)
             ],
             "a lone hull places its first escort that fits, counts the one it \
              tried before it, and tries no more"
@@ -2210,10 +2222,19 @@ mod tests {
                 hull_at(own, 12_000.0, &[15_900.0, 10_500.0]),
             ]),
             [
-                ("rock".into(), None),
-                ("escort_0".into(), Some(SkipType::Face)),
-                ("escort_1".into(), Some(SkipType::Clearance)),
-                ("hull".into(), Some(SkipType::Companion))
+                (sector_id(SectorCoord::ORIGIN, "rock", 0), None),
+                (
+                    sector_id(SectorCoord::ORIGIN, "escort_0", 0),
+                    Some(SkipType::Face)
+                ),
+                (
+                    sector_id(SectorCoord::ORIGIN, "escort_1", 0),
+                    Some(SkipType::Clearance)
+                ),
+                (
+                    sector_id(SectorCoord::ORIGIN, "hull", 0),
+                    Some(SkipType::Companion)
+                )
             ],
             "another cluster's rock is no companion, and no escort fits"
         );
@@ -2589,7 +2610,7 @@ mod tests {
         let escort_index = plan
             .bodies
             .iter()
-            .position(|body| body.id == "sector_2_0_1_cluster_1_0_1_rock_1")
+            .position(|body| body.id == sector_id(coord, "cluster_1_0_1_rock", 1))
             .unwrap();
         let misplaced = plan.bodies[escort_index].position;
         let hull_position =
@@ -2598,7 +2619,7 @@ mod tests {
         plan.bodies[escort_index].source = BodySource::Escort([1, 0, 1], 0, 0);
         plan.bodies[escort_index].position = original;
         plan.bodies.push(PlannedBody {
-            id: "artificial_paired_hull".to_string(),
+            id: sector_id(plan.coord, "artificial_paired_hull", 0),
             source: BodySource::Hull([1, 0, 1], 0),
             body: ClusterBody::Hull {
                 yaw: 0.0,
@@ -2674,7 +2695,7 @@ mod tests {
             world.input(coord),
         )
         .unwrap();
-        let id = "sector_2_0_1_cluster_1_0_1_rock_1";
+        let id = sector_id(coord, "cluster_1_0_1_rock", 1);
         let placed = first.bodies.iter().find(|body| body.id == id).unwrap();
         assert!(placed.skipped.is_none());
         assert_ne!(placed.position, original, "overlapping candidate must move");
diff --git a/examples/playable/world_sector_inspector.rs b/examples/playable/world_sector_inspector.rs
index 95a751506..ae0a5c278 100644
--- a/examples/playable/world_sector_inspector.rs
+++ b/examples/playable/world_sector_inspector.rs
@@ -471,15 +471,15 @@ fn collect_sector(
         .asteroids()
         .iter()
         .map(|body| InspectedObject {
-            id: body.id.clone(),
+            id: body.id.to_string(),
             clearance: Meters(body.config.radius.get() * ASTEROID_GEOMETRIC_FACTOR_MAX),
         })
         .chain(description.planets().iter().map(|planet| InspectedObject {
-            id: planet.id.clone(),
+            id: planet.id.to_string(),
             clearance: planet.config.body_radius(),
         }))
         .chain(description.ships().iter().map(|ship| InspectedObject {
-            id: ship.id.clone(),
+            id: ship.id.to_string(),
             clearance: ship.clearance,
         }))
         .collect();
diff --git a/examples/shared/world_fixture/clustered.rs b/examples/shared/world_fixture/clustered.rs
index a96e6d472..03f4e77de 100644
--- a/examples/shared/world_fixture/clustered.rs
+++ b/examples/shared/world_fixture/clustered.rs
@@ -394,7 +394,7 @@ pub enum Outcome {
 #[derive(Clone, Debug)]
 pub struct PlannedBody {
     /// Its scenario id, prefixed with the owning cell's slug.
-    pub id: String,
+    pub id: SectorObjectId,
     /// Where it came from.
     pub source: BodySource,
     /// What it is.
@@ -459,7 +459,7 @@ impl CellPlan {
                         kind: (*kind).into(),
                         destroy_sound: None,
                         lock_signature: None,
-                        seed: Some(asteroid_seed_from_id(&id)),
+                        seed: Some(asteroid_seed_from_id(id.as_str())),
                     },
                     id,
                     position,
@@ -940,7 +940,7 @@ pub fn plan_cell(
                     &civilizations,
                     input.seed,
                     HullSlot {
-                        id: &member.id,
+                        id: member.id.as_str(),
                         node: group.id.0,
                         slot: index,
                         anchor: group.anchor,
@@ -995,7 +995,7 @@ pub fn plan_cell(
 
 /// One body a cell owns, before it is resolved.
 struct Candidate {
-    id: String,
+    id: SectorObjectId,
     source: BodySource,
     body: ClusterBody,
     position: Meters3,
@@ -1027,7 +1027,7 @@ fn resolve(
     } in candidates
     {
         if !position.get().is_finite() {
-            return Err(SectorFault::InvalidGeometry { id });
+            return Err(SectorFault::InvalidGeometry { id: id.to_string() });
         }
         let clearance = body.clearance();
         let inside = SectorCoord::containing(position, edge) == input.coord
@@ -1043,7 +1043,7 @@ fn resolve(
         };
         if let (BodySource::Parent(..), Outcome::Skipped(reason)) = (source, outcome) {
             return Err(SectorFault::Generation {
-                id,
+                id: id.to_string(),
                 field: "parent",
                 value: format!("skipped for {}", reason.label()),
             });
@@ -1288,7 +1288,7 @@ mod tests {
         let input = config().input(SectorCoord::ORIGIN);
         let group = GroupId([0, 0, 0]);
         let hull = |id: &str, source: BodySource| Candidate {
-            id: id.to_string(),
+            id: sector_id(SectorCoord::ORIGIN, id, 0),
             source,
             body: ClusterBody::Hull {
                 yaw: 0.0,
@@ -1318,7 +1318,11 @@ mod tests {
         )
         .expect_err("a parent over a placed body must refuse");
         assert!(
-            matches!(&fault, SectorFault::Generation { id, field: "parent", .. } if id == "parent"),
+            matches!(
+                &fault,
+                SectorFault::Generation { id, field: "parent", .. }
+                    if *id == sector_id(SectorCoord::ORIGIN, "parent", 0).to_string()
+            ),
             "got {fault:?}"
         );
     }
diff --git a/examples/shared/world_fixture/uniform_asteroids.rs b/examples/shared/world_fixture/uniform_asteroids.rs
index b75ed9668..09beeed56 100644
--- a/examples/shared/world_fixture/uniform_asteroids.rs
+++ b/examples/shared/world_fixture/uniform_asteroids.rs
@@ -129,7 +129,7 @@ impl SectorGenerator for UniformAsteroids {
                 })
             else {
                 return Err(SectorFault::Clearance {
-                    id,
+                    id: id.to_string(),
                     attempts: PLACEMENT_ATTEMPTS,
                 });
             };
@@ -143,7 +143,7 @@ impl SectorGenerator for UniformAsteroids {
                     kind,
                     destroy_sound: None,
                     lock_signature: None,
-                    seed: Some(asteroid_seed_from_id(&id)),
+                    seed: Some(asteroid_seed_from_id(id.as_str())),
                 },
                 id,
                 position,

From 0767542f342f75df656000c6af51387d27d101a9 Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 11:46:55 +0300
Subject: [PATCH 09/22] Skip sync_open_world until a world session or config
 exists

Claim: `sync_open_world` ran every frame of every app that installs
NovaWorldBasePlugin, including a campaign that never opens a world.

Change: the system runs under
`resource_exists::.or_else(resource_exists::>)`
(bevy 0.19 deprecates `.or`). The arming tests call the system directly
and are unchanged; the system doc states the gate.

Verification: cargo fmt; cargo check -p nova_world -p nova_world_base
-p nova_scenario -p nova_menu --all-targets; cargo test
-p nova_world_base --lib (91 ok).

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_world_base/src/lib.rs | 17 +++++++++++++++--
 1 file changed, 15 insertions(+), 2 deletions(-)

diff --git a/crates/nova_world_base/src/lib.rs b/crates/nova_world_base/src/lib.rs
index 158f1cc47..d284ae016 100644
--- a/crates/nova_world_base/src/lib.rs
+++ b/crates/nova_world_base/src/lib.rs
@@ -176,8 +176,18 @@ impl Plugin for NovaWorldBasePlugin {
         // `StateTransition`, and a ship destroyed in `FixedUpdate` leaves the
         // world. Both land after `PreUpdate`, and the streaming stages would
         // then read a config nobody re-checked. Exclusive, so its writes are
-        // in the world before `Cleanup` decides what to clear.
-        app.add_systems(Update, sync_open_world.before(NovaWorldSystems::Cleanup));
+        // in the world before `Cleanup` decides what to clear. Skipped until
+        // New Game or a Load inserts the session, or a config is armed: a
+        // campaign that never opened a world pays nothing for it.
+        app.add_systems(
+            Update,
+            sync_open_world
+                .run_if(
+                    resource_exists::
+                        .or_else(resource_exists::>),
+                )
+                .before(NovaWorldSystems::Cleanup),
+        );
         // The saved world, native only. Restore runs on the arming frame,
         // after `Cleanup` cleared the ledger and before `Observe` asks the
         // first cells for their records. The save runs after `Retire`, so the
@@ -219,6 +229,9 @@ impl Plugin for NovaWorldBasePlugin {
 ///   its ship-part snapshot from [`LoadedSectionPacks`], once, and checks
 ///   [`GameStyles`] for every role's style.
 ///
+/// The plugin runs it only while an [`OpenWorldSession`] or an armed
+/// [`WorldConfig`] exists; an app that never opened a world skips it.
+///
 /// # Panics
 ///
 /// An open-world scenario with no [`OpenWorldSession`] has no seed. Two or

From a364eebc0739631a0ee3fcb1e9fbb5faef71b833 Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 11:48:08 +0300
Subject: [PATCH 10/22] Export live_sectors through the nova_world prelude

Claim: save/transients.rs reached `live_sectors`, `SectorRoot` and
`SectorSnapshotError` through crate paths because the prelude lacked
`live_sectors`.

Change: add `live_sectors` to `nova_world::prelude`; transients.rs
imports from the prelude only.

Verification: cargo fmt; cargo check -p nova_world -p nova_world_base
-p nova_scenario -p nova_menu --all-targets; cargo check --examples
--keep-going.

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_world/src/lib.rs                  | 4 ++--
 crates/nova_world_base/src/save/transients.rs | 7 +++----
 2 files changed, 5 insertions(+), 6 deletions(-)

diff --git a/crates/nova_world/src/lib.rs b/crates/nova_world/src/lib.rs
index 422d59e64..01b1e8d76 100644
--- a/crates/nova_world/src/lib.rs
+++ b/crates/nova_world/src/lib.rs
@@ -221,8 +221,8 @@ pub mod prelude {
             SectorSnapshotError,
         },
         streaming::{
-            desired_sectors, CurrentSector, PendingSectorShip, ReadySectors, SectorJob,
-            SectorJobStats, SectorRoot, WorldObserver,
+            desired_sectors, live_sectors, CurrentSector, PendingSectorShip, ReadySectors,
+            SectorJob, SectorJobStats, SectorRoot, WorldObserver,
         },
     };
 }
diff --git a/crates/nova_world_base/src/save/transients.rs b/crates/nova_world_base/src/save/transients.rs
index c069a1ea2..6941234f8 100644
--- a/crates/nova_world_base/src/save/transients.rs
+++ b/crates/nova_world_base/src/save/transients.rs
@@ -40,10 +40,9 @@ use nova_ship::prelude::{
     FrozenShedFixture, FrozenTorpedo, PlaceholderArt, SavedTorpedoTarget, ShedFixtureMarker,
     SkinAssets, TorpedoTargetEntity,
 };
-use nova_world::{
-    desired_sectors, live_sectors,
-    prelude::{CurrentSector, SectorCoord, WorldConfig},
-    SectorRoot, SectorSnapshotError,
+use nova_world::prelude::{
+    desired_sectors, live_sectors, CurrentSector, SectorCoord, SectorRoot, SectorSnapshotError,
+    WorldConfig,
 };
 use serde::{Deserialize, Serialize};
 

From c26d8048b9547fb59abf28e49c468b5f6cb2393d Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 11:48:12 +0300
Subject: [PATCH 11/22] State what a world save records in the
 WORLD_SAVE_FORMAT doc

Claim: the doc said a save holds only the sectors the player changed;
every cell the window held live is recorded as Visited and kept.

Change: doc only. The save records each live cell as Visited on every
save and never drops one, records a body that moved into an unheld cell
as an arrival, and still needs a format bump on a generator change
because unvisited cells regenerate from the seed.

Verification: cargo check -p nova_world_base --all-targets (part of the
check run above).

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_world_base/src/save/mod.rs | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/crates/nova_world_base/src/save/mod.rs b/crates/nova_world_base/src/save/mod.rs
index cc1812f3c..e07c7a229 100644
--- a/crates/nova_world_base/src/save/mod.rs
+++ b/crates/nova_world_base/src/save/mod.rs
@@ -55,8 +55,11 @@ pub use transients::{
 /// The save layout this build reads and writes.
 ///
 /// Bump it with any change to the save layout, or to what the generator
-/// builds from a seed: an old save holds only the sectors the player changed
-/// and regenerates the rest, so another generator would change it silently.
+/// builds from a seed. A save records every cell the window held live as
+/// Visited, on each save, and never drops one; a body that moved into a cell
+/// nobody held live is recorded as an arrival there. Every cell the player
+/// never visited regenerates from the seed on Load, so another generator
+/// would change it silently.
 pub const WORLD_SAVE_FORMAT: u32 = 3;
 
 /// The most characters a world name has, after trimming.

From a08a1b78309aeac12919ad0125a5bf5a65ae0d7f Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 11:58:20 +0300
Subject: [PATCH 12/22] Fail the save on an unrecorded transient and check
 saved stock caps

Claim: a top-level transient with a physics body and no save record
panicked the game mid-save, unlike every other save fault; a saved hold
or canister over its cap was neither refused on open nor before write.

Change: `TransientKind::Unknown` returns
`SectorSnapshotError::InvalidSavedState`, so the session goes
`WorldSaveStatus::Failed` and keeps the last save. New
`check_saved_stock(state, items)` beside `check_saved_items` in
save/mod.rs: the player's and every ledger ship's hold weighs at most
its capacity, every ledger canister at most CARGO_CANISTER_MAX_MASS_G;
`read_state` maps it to `WorldRefusal::Unreadable` and `snapshot_world`
runs it before the write. `FrozenShip::inventory()` and
`FrozenCanister::contents()` expose the frozen stock read-only. The
retire-path panic in nova_world stays.

Verification: cargo fmt; cargo check -p nova_world -p nova_world_base
-p nova_scenario -p nova_menu -p nova_ship --all-targets; cargo check
--examples --keep-going; cargo test -p nova_world_base --lib (93 ok,
with the two new behaviour tests); cargo test -p nova_scenario --lib
spaceship (20 ok).

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_scenario/src/objects/spaceship.rs |   7 +
 .../src/sections/cargo_intake_section.rs      |   7 +
 crates/nova_world_base/src/save/mod.rs        |  66 ++++++++-
 crates/nova_world_base/src/save/session.rs    |   3 +-
 crates/nova_world_base/src/save/tests.rs      | 127 +++++++++++++++++-
 crates/nova_world_base/src/save/transients.rs |  24 ++--
 6 files changed, 219 insertions(+), 15 deletions(-)

diff --git a/crates/nova_scenario/src/objects/spaceship.rs b/crates/nova_scenario/src/objects/spaceship.rs
index f4602fced..7c05e1a21 100644
--- a/crates/nova_scenario/src/objects/spaceship.rs
+++ b/crates/nova_scenario/src/objects/spaceship.rs
@@ -632,6 +632,13 @@ impl FrozenShip {
     pub fn has_section(&self, section: &str) -> bool {
         self.state.sections.contains_key(section)
     }
+
+    /// The hold the ship froze with, as the save checks it against its
+    /// capacity.
+    #[must_use]
+    pub fn inventory(&self) -> &ShipInventory {
+        &self.state.inventory
+    }
 }
 
 /// The ship a scenario's spawn of [`ResumedSpaceship::id`] thaws instead of
diff --git a/crates/nova_ship/src/sections/cargo_intake_section.rs b/crates/nova_ship/src/sections/cargo_intake_section.rs
index 92b173119..1df6fe255 100644
--- a/crates/nova_ship/src/sections/cargo_intake_section.rs
+++ b/crates/nova_ship/src/sections/cargo_intake_section.rs
@@ -294,6 +294,13 @@ impl FrozenCanister {
     pub fn item_ids(&self) -> impl Iterator {
         self.contents.stacks().map(|(item, _)| item)
     }
+
+    /// The stacks the canister froze with, as the save checks them against
+    /// the canister cap.
+    #[must_use]
+    pub fn contents(&self) -> &CargoCanister {
+        &self.contents
+    }
 }
 
 /// Capture `canister`'s id, contents, health and model.
diff --git a/crates/nova_world_base/src/save/mod.rs b/crates/nova_world_base/src/save/mod.rs
index e07c7a229..8b675d4a7 100644
--- a/crates/nova_world_base/src/save/mod.rs
+++ b/crates/nova_world_base/src/save/mod.rs
@@ -31,8 +31,8 @@ use bevy::prelude::*;
 use nova_assets::{prelude::LoadedSectionPacks, storage::write_atomic};
 use nova_events::prelude::EntityId;
 use nova_gameplay::prelude::{
-    CargoCanisterRuntimeId, GameItems, ItemDesignId, SavedBodyRef, SavedOwner, SavedSectionRef,
-    SavedTargetRef,
+    kg_text, CargoCanisterRuntimeId, GameItems, ItemDesignId, SavedBodyRef, SavedOwner,
+    SavedSectionRef, SavedTargetRef, CARGO_CANISTER_MAX_MASS_G,
 };
 use nova_scenario::prelude::FrozenShip;
 use nova_ship::prelude::{CameraView, SavedTorpedoTarget};
@@ -362,6 +362,8 @@ fn read_state(
         .map_err(|fault| WorldRefusal::Unreadable(format!("{state_name}: {fault}")))?;
     check_saved_items(&state, items)
         .map_err(|fault| WorldRefusal::Unreadable(format!("{state_name}: {fault}")))?;
+    check_saved_stock(&state, items)
+        .map_err(|fault| WorldRefusal::Unreadable(format!("{state_name}: {fault}")))?;
     Ok(state)
 }
 
@@ -406,6 +408,66 @@ fn check_saved_items(state: &WorldSaveState, items: &GameItems) -> Result<(), St
     Ok(())
 }
 
+/// Every saved hold is within its cap: the player's and every ledger ship's
+/// stock weighs at most its hull capacity, and every ledger canister weighs
+/// at most [`CARGO_CANISTER_MAX_MASS_G`]. Runs after [`check_saved_items`],
+/// so every stacked id has a mass. A save checks before it writes; Load
+/// checks on open, as a thaw would otherwise spawn a hold the game never
+/// lets a ship take.
+fn check_saved_stock(state: &WorldSaveState, items: &GameItems) -> Result<(), String> {
+    let mass_g = |stacks: &mut dyn Iterator| -> u64 {
+        stacks
+            .map(|(item, count)| items.design(item).stack_mass_g(count))
+            .sum()
+    };
+    let check_hold = |ship: &FrozenShip, owner: &str| -> Result<(), String> {
+        let hold = ship.inventory();
+        let held = mass_g(&mut hold.stacks());
+        let capacity = u64::from(hold.capacity_g());
+        if held > capacity {
+            return Err(format!(
+                "{owner} holds {} in a hold of {}",
+                kg_text(held),
+                kg_text(capacity)
+            ));
+        }
+        Ok(())
+    };
+    check_hold(
+        &state.player.ship,
+        &format!("the player ship '{}'", state.player.id.0),
+    )?;
+    for (_, record) in state.sectors.iter() {
+        for body in record.bodies() {
+            match body.body() {
+                FrozenBodyType::Ship(ship) => {
+                    let id = body
+                        .id()
+                        .expect("a frozen ship is a persistent body with an id");
+                    check_hold(ship, &format!("'{id}'"))?;
+                }
+                FrozenBodyType::Canister(canister) => {
+                    let held = mass_g(&mut canister.contents().stacks());
+                    let cap = u64::from(CARGO_CANISTER_MAX_MASS_G);
+                    if held > cap {
+                        return Err(format!(
+                            "the canister {} holds {} over the canister cap of {}",
+                            canister.id().0,
+                            kg_text(held),
+                            kg_text(cap)
+                        ));
+                    }
+                }
+                FrozenBodyType::Asteroid(_)
+                | FrozenBodyType::PendingShip { .. }
+                | FrozenBodyType::WreckFragment(_)
+                | FrozenBodyType::OreDrop(_) => {}
+            }
+        }
+    }
+    Ok(())
+}
+
 /// Why the ids of a saved state cannot open.
 #[derive(Debug)]
 enum SavedIdFault {
diff --git a/crates/nova_world_base/src/save/session.rs b/crates/nova_world_base/src/save/session.rs
index 1ad5b468c..70b664d71 100644
--- a/crates/nova_world_base/src/save/session.rs
+++ b/crates/nova_world_base/src/save/session.rs
@@ -30,7 +30,7 @@ use nova_world::prelude::{
 };
 
 use super::{
-    check_saved_ids, check_saved_items,
+    check_saved_ids, check_saved_items, check_saved_stock,
     transients::{freeze_transients, hold_resumed_transients},
     write_world, SavedIdFault, SavedPlayer, WorldFolder, WorldLock, WorldSaveHeader,
     WorldSaveState, WORLD_SAVE_FORMAT,
@@ -494,6 +494,7 @@ pub(crate) fn snapshot_world(world: &mut World) {
         })
         .and_then(|()| {
             check_saved_items(&state, &items)
+                .and_then(|()| check_saved_stock(&state, &items))
                 .map_err(|reason| SectorSnapshotError::InvalidSavedState { reason })
         });
     if let Err(error) = checked {
diff --git a/crates/nova_world_base/src/save/tests.rs b/crates/nova_world_base/src/save/tests.rs
index 00d276036..acb1bf087 100644
--- a/crates/nova_world_base/src/save/tests.rs
+++ b/crates/nova_world_base/src/save/tests.rs
@@ -13,7 +13,8 @@ use nova_gameplay::prelude::{
     PlayerSpaceshipMarker, PointRotationOutput, ProjectileDamage, ProjectileOwner,
     RailgunSlugProjectileMarker, RoundRake, SavedBodyRef, SavedLifetime, SavedOwner,
     SavedSectionRef, SavedTargetRef, SectionMarker, ShipCredits, ShipInventory,
-    SpaceshipRootMarker, TorpedoProjectileMarker, TurretBulletProjectileMarker, ITEM_IRON_ORE,
+    SpaceshipRootMarker, TempEntity, TorpedoProjectileMarker, TurretBulletProjectileMarker,
+    ITEM_IRON_ORE, ITEM_TORPEDO,
 };
 use nova_scenario::prelude::{
     freeze_asteroid, freeze_ore_drop, freeze_ship, spaceship_scenario_object, AsteroidKind,
@@ -2302,3 +2303,127 @@ fn a_save_holding_an_item_the_catalog_lacks_fails_and_keeps_the_last_save() {
     assert_eq!(files(&folder), ["state.1.ron", "world.lock", "world.ron"]);
     assert_eq!(std::fs::read(folder.join("state.1.ron")).unwrap(), saved);
 }
+
+/// A live top-level transient with a physics body and no save record fails
+/// the save visibly and writes nothing, so the last good save stays; the
+/// game keeps running.
+#[test]
+fn a_save_over_a_physics_transient_with_no_record_fails_and_keeps_the_last_save() {
+    let root = tempfile::tempdir().unwrap();
+    let (mut world, _player) = armed_session(root.path());
+    run_until_idle(&mut world);
+    assert_eq!(
+        world.resource::().status(),
+        &WorldSaveStatus::Saved { generation: 1 }
+    );
+    let folder = root.path().join("session");
+    let saved = std::fs::read(folder.join("state.1.ron")).unwrap();
+
+    world.spawn((
+        Name::new("Mystery Debris"),
+        TempEntity(5.0),
+        RigidBody::Dynamic,
+        Transform::IDENTITY,
+    ));
+    world.resource_mut::().request_leave();
+    run_until_idle(&mut world);
+    let WorldSaveStatus::Failed(reason) = world.resource::().status() else {
+        panic!(
+            "a transient with no record fails the save, not {:?}",
+            world.resource::().status()
+        );
+    };
+    assert!(
+        reason.contains("'Mystery Debris' is a transient with a physics body and no save record"),
+        "{reason}"
+    );
+    assert_eq!(files(&folder), ["state.1.ron", "world.lock", "world.ron"]);
+    assert_eq!(std::fs::read(folder.join("state.1.ron")).unwrap(), saved);
+}
+
+/// A saved world whose player hold weighs more than its capacity, or whose
+/// ledger holds a canister over the canister cap, is refused on open with
+/// the holder and both masses; the same holds within their caps open.
+#[test]
+fn a_world_whose_saved_stock_is_over_its_hold_or_canister_cap_is_refused_on_open() {
+    let root = tempfile::tempdir().unwrap();
+    let (folder, lock) = create_world(root.path(), "Stock").unwrap();
+    drop(lock);
+    let east = SectorCoord::new(1, 0, 0);
+    let iron = ron::to_string(&ItemDesignId::from(ITEM_IRON_ORE)).unwrap();
+    let torpedo = ron::to_string(&ItemDesignId::from(ITEM_TORPEDO)).unwrap();
+    // Read from RON, as a load reads them: the constructors refuse both.
+    let player_with_hold = |capacity_g: u32| {
+        let mut world = World::new();
+        let ship = world
+            .spawn((
+                spaceship_scenario_object(SpaceshipConfig::default()),
+                ron::from_str::(&format!(
+                    "(capacity_g: {capacity_g}, stacks: {{{iron}: 1}})"
+                ))
+                .unwrap(),
+                DamageMarks::default(),
+            ))
+            .id();
+        SavedPlayer {
+            ship: freeze_ship(&world, ship).expect("a bare ship is settled"),
+            ..state(1, 750).player
+        }
+    };
+    let canister_holding = |torpedoes: u32| {
+        let mut world = World::new();
+        let canister = world
+            .spawn((
+                cargo_canister(
+                    ron::from_str::(&format!(
+                        "(stacks: {{{torpedo}: {torpedoes}}})"
+                    ))
+                    .unwrap(),
+                    Transform::IDENTITY,
+                    Vec3::ZERO,
+                    AssetRef::from("canister.glb#Scene0"),
+                ),
+                CargoCanisterRuntimeId(5),
+            ))
+            .id();
+        let frozen = freeze_canister(&world, canister).expect("a whole canister");
+        ledger_holding(east, None, &FrozenBodyType::Canister(frozen))
+    };
+
+    let cases = [
+        (
+            player_with_hold(5_000),
+            FrozenSectors::default(),
+            "the player ship 'player' holds 10 kg in a hold of 5 kg",
+        ),
+        (
+            player_with_hold(10_000),
+            canister_holding(2),
+            "the canister 5 holds 300 kg over the canister cap of 200 kg",
+        ),
+    ];
+    for (generation, (player, sectors, expected)) in (1..).zip(cases) {
+        let saved_state = WorldSaveState {
+            player,
+            sectors,
+            ..state(generation, 750)
+        };
+        write_world(&folder, &header("Stock", generation), &saved_state).unwrap();
+        assert_eq!(
+            open_world(root.path(), "stock", &catalog(7), &items()).map(|_| ()),
+            Err(WorldRefusal::Unreadable(format!(
+                "state.{generation}.ron: {expected}"
+            ))),
+            "case at generation {generation}"
+        );
+    }
+
+    let within = WorldSaveState {
+        player: player_with_hold(10_000),
+        sectors: canister_holding(1),
+        ..state(3, 750)
+    };
+    write_world(&folder, &header("Stock", 3), &within).unwrap();
+    let (_, _lock, _, saved) = open_world(root.path(), "stock", &catalog(7), &items()).unwrap();
+    assert_eq!(saved.generation, 3, "stock within its caps opens");
+}
diff --git a/crates/nova_world_base/src/save/transients.rs b/crates/nova_world_base/src/save/transients.rs
index 6941234f8..f58d3ee67 100644
--- a/crates/nova_world_base/src/save/transients.rs
+++ b/crates/nova_world_base/src/save/transients.rs
@@ -3,9 +3,9 @@
 //!
 //! A save freezes every live top-level [`TempEntity`] body by its kind. A
 //! body in a multi-frame process makes the save wait, and a body this module
-//! has no record for panics, so no transient is dropped without a decision.
-//! A body with no physics, such as a spark or a light, is visual only and is
-//! not saved.
+//! has no record for fails the save, so no transient is dropped without a
+//! decision. A body with no physics, such as a spark or a light, is visual
+//! only and is not saved.
 //!
 //! A Load keeps the records in [`ResumedTransients`] and holds the clocks
 //! ([`FreezeOwner::WorldResume`]) until every desired sector is live and every
@@ -197,11 +197,9 @@ pub(crate) fn hold_resumed_transients(world: &mut World, transients: Vec Result, SectorSnapshotError> {
@@ -273,9 +271,13 @@ pub(crate) fn freeze_transients(
             TransientKind::DetachedPiece => {
                 freeze_detached_piece(world, entity).map(FrozenTransientType::DetachedPiece)
             }
-            TransientKind::Unknown => panic!(
-                "nova_world_base: '{label}' is a transient with a physics body and no save record"
-            ),
+            TransientKind::Unknown => {
+                return Err(SectorSnapshotError::InvalidSavedState {
+                    reason: format!(
+                        "'{label}' is a transient with a physics body and no save record"
+                    ),
+                });
+            }
         };
         let body = body.map_err(|fault| match fault {
             TransientFreezeFault::Unsettled(reason) => SectorSnapshotError::Unsettled {

From 97d3ad75cb52794afba941cfeeae225046a6e754 Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 12:04:05 +0300
Subject: [PATCH 13/22] Open a saved world off the main thread on Load

Claim: the Load button ran `open_world` (lock, header and state read,
every stock check) synchronously on the main thread, inside the click
observer.

Change: `on_load_world` spawns the open on `IoTaskPool` into the new
`WorldOpen` resource and ignores clicks while one runs; `poll_world_open`
takes the finished task in the Load picker's Update chain, before the
list redraw, and resumes the world or writes the refusal onto the row
exactly as the click did. The refusal UI is unchanged. The picker tests
run frames until the open lands.

Verification: cargo fmt; cargo check -p nova_world -p nova_world_base
-p nova_scenario -p nova_menu --all-targets; cargo check --examples
--keep-going; cargo test -p nova_menu --lib load_screen (3 ok).

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_menu/src/lib.rs               | 11 ++--
 crates/nova_menu/src/load_screen.rs       | 61 +++++++++++++++++------
 crates/nova_menu/src/tests/load_screen.rs | 24 +++++++--
 3 files changed, 75 insertions(+), 21 deletions(-)

diff --git a/crates/nova_menu/src/lib.rs b/crates/nova_menu/src/lib.rs
index c72491573..360f9925b 100644
--- a/crates/nova_menu/src/lib.rs
+++ b/crates/nova_menu/src/lib.rs
@@ -86,8 +86,9 @@ use ambience::{
 };
 #[cfg(not(target_arch = "wasm32"))]
 use load_screen::{
-    poll_world_checks, refresh_load_details, refresh_load_list, refuse_resumed_world,
-    SelectedWorldSlug, WorldChecks, WorldDeleteStep, WorldListings,
+    poll_world_checks, poll_world_open, refresh_load_details, refresh_load_list,
+    refuse_resumed_world, SelectedWorldSlug, WorldChecks, WorldDeleteStep, WorldListings,
+    WorldOpen,
 };
 use menu_ui::{setup_menu_ui, start_new_game_scenario};
 use mods::{
@@ -168,6 +169,7 @@ impl Plugin for NovaMenuPlugin {
         {
             app.init_resource::();
             app.init_resource::();
+            app.init_resource::();
             app.init_resource::();
             app.init_resource::();
         }
@@ -311,13 +313,14 @@ impl Plugin for NovaMenuPlugin {
         // writes the default/repaired selection, the details pane renders it
         // in the same frame. Both run off `resource_changed`, not a dirty
         // function - nothing here has a live registry to watch, only the
-        // three resources `on_load_screen`, `on_load_world`, the delete flow
-        // and `poll_world_checks` write.
+        // three resources `on_load_screen`, `poll_world_open`, the delete
+        // flow and `poll_world_checks` write.
         #[cfg(not(target_arch = "wasm32"))]
         app.add_systems(
             Update,
             (
                 poll_world_checks,
+                poll_world_open,
                 refresh_load_list.run_if(resource_changed::),
                 refresh_load_details.run_if(
                     resource_changed::
diff --git a/crates/nova_menu/src/load_screen.rs b/crates/nova_menu/src/load_screen.rs
index 8e54db41d..ac42615e2 100644
--- a/crates/nova_menu/src/load_screen.rs
+++ b/crates/nova_menu/src/load_screen.rs
@@ -7,7 +7,8 @@
 //! file is read and every item it holds is looked up in the loaded catalog.
 //! Until that check ends the row shows "Checking saved state" and Load is
 //! greyed; a refused check replaces the row's header with the refusal. Load
-//! checks again as it opens the world.
+//! checks again as it opens the world, off the main thread as well
+//! ([`WorldOpen`]); the world resumes on the frame the open lands.
 
 use std::{collections::HashMap, path::Path, sync::Arc};
 
@@ -25,8 +26,8 @@ use nova_ui::{
     widget::{list_row, themed_button, ButtonVariant, Selected, ThemedText, UiText},
 };
 use nova_world_base::prelude::{
-    check_world, delete_world, list_worlds, open_world, resume_world, WorldListing, WorldRefusal,
-    WorldResumeRefused, WorldSaveSession,
+    check_world, delete_world, list_worlds, open_world, resume_world, WorldFolder, WorldListing,
+    WorldLock, WorldRefusal, WorldResumeRefused, WorldSaveHeader, WorldSaveSession, WorldSaveState,
 };
 
 use crate::{scenarios::NewGameScenario, widgets::button_variant, world_setup::WorldsRoot};
@@ -60,6 +61,15 @@ impl WorldChecks {
     }
 }
 
+/// What [`open_world`] hands back: the world to resume, or why not.
+type OpenedWorld = Result<(WorldFolder, WorldLock, WorldSaveHeader, WorldSaveState), WorldRefusal>;
+
+/// The world the Load button is opening off the main thread, by slug.
+/// [`on_load_world`] starts it and ignores clicks until [`poll_world_open`]
+/// takes the result; dropping the task cancels the open.
+#[derive(Resource, Default)]
+pub(crate) struct WorldOpen(pub(crate) Option<(String, Task)>);
+
 /// The text a row and the details pane show while the world is checked.
 const CHECKING_TEXT: &str = "Checking saved state";
 
@@ -264,34 +274,57 @@ pub(crate) fn on_delete_world_confirm(
     };
 }
 
-/// Load the selected world, checked again as it opens. On `Err` (a world
-/// locked or refused since the list was read), the refusal replaces that
-/// row's header so the list and details redraw with it; nothing starts. On
-/// `Ok`, resume the world and hand off to Playing exactly like New Game.
+/// Start opening the selected world off the main thread, checked again as
+/// it opens; [`poll_world_open`] finishes the Load. A click while a check or
+/// an open is still running does nothing.
 pub(crate) fn on_load_world(
     _activate: On,
-    mut commands: Commands,
     root: Res,
     packs: Res,
     items: Res,
     checks: Res,
     selected: Res,
-    mut listings: ResMut,
+    mut opening: ResMut,
     mut step: ResMut,
-    mut pick: ResMut,
-    mut mode: ResMut,
-    mut state: ResMut>,
 ) {
     let (Some(root), Some(slug)) = (root.0.as_deref(), selected.0.as_deref()) else {
         return;
     };
-    if checks.pending(slug) {
+    if checks.pending(slug) || opening.0.is_some() {
         return;
     }
     // A Load drops a Delete asked about, so a refused resume does not bring
     // its prompt back.
     *step = WorldDeleteStep::Idle;
-    match open_world(root, slug, &packs, &items) {
+    let (root, slug) = (root.to_path_buf(), slug.to_string());
+    let (packs, items) = (packs.clone(), items.clone());
+    let task = {
+        let slug = slug.clone();
+        IoTaskPool::get().spawn(async move { open_world(&root, &slug, &packs, &items) })
+    };
+    opening.0 = Some((slug, task));
+}
+
+/// Take a finished [`WorldOpen`]. On `Err` (a world locked or refused since
+/// the list was read), the refusal replaces that row's header so the list
+/// and details redraw with it; nothing starts. On `Ok`, resume the world and
+/// hand off to Playing exactly like New Game.
+pub(crate) fn poll_world_open(
+    mut commands: Commands,
+    mut opening: ResMut,
+    mut listings: ResMut,
+    mut pick: ResMut,
+    mut mode: ResMut,
+    mut state: ResMut>,
+) {
+    let Some((_, task)) = &mut opening.0 else {
+        return;
+    };
+    let Some(result) = block_on(future::poll_once(task)) else {
+        return;
+    };
+    let (slug, _) = opening.0.take().expect("the open polled above");
+    match result {
         Ok((folder, lock, header, world_state)) => {
             commands.queue(move |world: &mut World| {
                 resume_world(world, folder, lock, &header, world_state);
diff --git a/crates/nova_menu/src/tests/load_screen.rs b/crates/nova_menu/src/tests/load_screen.rs
index d8fd66b89..f3dd3192a 100644
--- a/crates/nova_menu/src/tests/load_screen.rs
+++ b/crates/nova_menu/src/tests/load_screen.rs
@@ -13,7 +13,7 @@ use nova_world_base::{
 
 use super::support::{app, dummy_scenarios};
 use crate::{
-    load_screen::{SelectedWorldSlug, WorldChecks},
+    load_screen::{SelectedWorldSlug, WorldChecks, WorldOpen},
     scenarios::NewGameScenario,
     world_setup::WorldsRoot,
 };
@@ -82,6 +82,24 @@ fn checks_settle(app: &mut App) {
     panic!("the world checks did not settle in time");
 }
 
+/// Press the Load World button and run `app` until its [`WorldOpen`] lands
+/// and the frame after it applies the state it asked for, or panic after a
+/// generous number of frames - the open runs off the main thread on the
+/// `IoTaskPool`, so more than one frame can pass before the world resumes or
+/// the refusal shows.
+fn press_load_world(app: &mut App) {
+    press(app, "Load World Button");
+    for _ in 0..200 {
+        if app.world().resource::().0.is_none() {
+            app.update();
+            return;
+        }
+        thread::sleep(Duration::from_millis(5));
+        app.update();
+    }
+    panic!("the world open did not land in time");
+}
+
 /// Whether any entity in `app` is named `name`.
 fn has_named(app: &mut App, name: &str) -> bool {
     let mut names = app.world_mut().query::<&Name>();
@@ -156,7 +174,7 @@ fn load_lists_a_saved_world_and_a_refused_one_and_loads_only_the_saved_one() {
         "a saved world's Load button is not greyed"
     );
 
-    press(&mut app, "Load World Button");
+    press_load_world(&mut app);
 
     assert_eq!(
         app.world().resource::().name(),
@@ -296,7 +314,7 @@ fn load_greys_a_world_while_its_check_runs_and_refuses_one_holding_an_unknown_it
             .filter(|design| design.id != hull_plate)
             .cloned(),
     ));
-    press(&mut app, "Load World Button");
+    press_load_world(&mut app);
     assert_eq!(
         state(&app),
         GameStates::MainMenu,

From 3ee7479944376331fc3ee025ac47a646b95c99d9 Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 12:25:15 +0300
Subject: [PATCH 14/22] Type the engine's item roles as ItemRoleType

Claim: the ten items engine code spends, reloads, mines and draws were a
string table (ITEM_ROLES and ten ITEM_* consts), so nothing was exhaustive
over them and every use allocated a fresh ItemDesignId.

Change: `ItemRoleType` in nova_gameplay::inventory, a closed enum with
`ALL`, `const fn id()` (the authored catalog id) and `const fn category()`;
`From for ItemDesignId`; `Borrow for ItemDesignId` so
`GameItems::role(ItemRoleType)` looks a role up without allocating and panics
with the role name when the catalog lacks it. ITEM_ROLES and the ITEM_*
consts are deleted; every consumer in crates/ and examples/ goes through the
enum. `ore_for_asteroid_kind` returns `Option`; `BeamAim`
carries the role and `carve` clones the catalog id from `GameItems::role`
per pulse; `ship_stock` mixes are `(ItemRoleType, f32)` and clone catalog
ids; wfc_arena's AMMO_HOLD_SHARE is keyed by role. The three section reload
inserts keep a per-section `.into()` (no GameItems in those observers; one
allocation per section spawn, as before).

Verification: cargo fmt; cargo check --all-targets on nova_gameplay,
nova_ship, nova_scenario, nova_world_base, nova_authoring, nova_wfc,
nova_editor, nova_console, nova_assets, nova_hud, nova_interface, nova_probe,
nova_menu, nova_world clean; cargo check --examples --keep-going clean;
cargo test -p nova_gameplay --lib inventory 10 passed; -p nova_assets --lib
items 3 passed; -p nova_scenario --lib mining 9 passed; -p nova_world_base
--lib stock 1 passed; --example wfc_arena reserve 4 passed.

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_assets/src/items.rs               |  55 ++--
 .../nova_authoring/src/base_content/items.rs  |  34 +--
 .../base_content/scenarios/main_menu/duel.rs  |   2 +-
 .../src/base_content/scenarios/open_world.rs  |  12 +-
 .../base_content/scenarios/tutorial/range.rs  |   2 +-
 crates/nova_console/src/cheats.rs             |  14 +-
 crates/nova_gameplay/src/inventory.rs         | 270 +++++++++++-------
 crates/nova_gameplay/src/test_support.rs      |  31 +-
 crates/nova_hud/src/ammo_readout.rs           |  22 +-
 crates/nova_hud/src/pickup_sight.rs           |   6 +-
 crates/nova_hud/src/situation.rs              |   4 +-
 crates/nova_interface/src/inventory/tests.rs  |  51 ++--
 crates/nova_interface/src/ship/scene.rs       |   8 +-
 crates/nova_interface/src/ship/sections.rs    |   4 +-
 crates/nova_interface/src/ship/tests.rs       |  22 +-
 crates/nova_menu/src/tests/load_screen.rs     |   2 +-
 .../nova_probe/src/capabilities/snapshot.rs   |  26 +-
 crates/nova_scenario/src/actions/spawn.rs     |   2 +-
 crates/nova_scenario/src/lint/scenario.rs     |  16 +-
 crates/nova_scenario/src/mining.rs            |  45 +--
 crates/nova_scenario/src/objects/beacon.rs    |   2 +-
 crates/nova_scenario/src/objects/spaceship.rs |   7 +-
 crates/nova_ship/src/input/targeting/radar.rs |   2 +-
 crates/nova_ship/src/sections/ammo.rs         |  26 +-
 .../sections/cargo_intake_section/tests.rs    |  30 +-
 .../src/sections/railgun_section/firing.rs    |   7 +-
 .../src/sections/railgun_section/tests.rs     |   2 +-
 .../src/sections/torpedo_section/bay.rs       |  11 +-
 .../src/sections/turret_section/firing.rs     |   6 +-
 .../src/sections/turret_section/setup.rs      |   7 +-
 crates/nova_world/src/tests.rs                |   8 +-
 crates/nova_world/src/tests/frozen.rs         |  14 +-
 crates/nova_world_base/src/save/tests.rs      |  13 +-
 crates/nova_world_base/src/sector_ships.rs    |  62 ++--
 crates/nova_world_base/src/tests.rs           |   4 +-
 examples/playable/mine_and_sell.rs            |   2 +-
 examples/playable/trade_loop.rs               |  10 +-
 examples/playable/wfc_arena.rs                |  49 ++--
 examples/playable/world_ships.rs              |   6 +-
 examples/screenshots/lesson_cargo.rs          |  10 +-
 examples/screenshots/lesson_combat_battery.rs |   4 +-
 examples/screenshots/lesson_combat_reach.rs   |   2 +-
 .../screenshots/lesson_generated_world.rs     |   2 +-
 examples/screenshots/lesson_interface_ship.rs |   8 +-
 .../screenshots/screenshot_mining_beam.rs     |   4 +-
 .../screenshots/screenshot_ship_repair.rs     |   4 +-
 examples/systems/system_world_resume.rs       |   2 +-
 47 files changed, 526 insertions(+), 406 deletions(-)

diff --git a/crates/nova_assets/src/items.rs b/crates/nova_assets/src/items.rs
index 7dc17db82..d14804a3f 100644
--- a/crates/nova_assets/src/items.rs
+++ b/crates/nova_assets/src/items.rs
@@ -14,7 +14,7 @@
 
 use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
 
-use nova_gameplay::prelude::{ItemDesign, CARGO_CANISTER_MAX_MASS_G, ITEM_ROLES};
+use nova_gameplay::prelude::{ItemDesign, ItemRoleType, CARGO_CANISTER_MAX_MASS_G};
 use nova_mod_format::deps::{transitive_deps, DepGraph};
 use nova_modding::prelude::BASE_MOD_ID;
 use nova_scenario::prelude::{ore_for_asteroid_kind, AsteroidKindId, ASTEROID_KINDS};
@@ -42,7 +42,7 @@ pub struct ItemPack<'a> {
 /// A pack is refused for an invalid item it defines, an item id it defines
 /// twice, an item id it shares with a pack it has no one-way dependency
 /// chain with, and a dependency chain too deep to walk. The base game is
-/// also refused when it lacks one of [`ITEM_ROLES`].
+/// also refused when it lacks one of [`ItemRoleType::ALL`].
 pub fn item_pack_faults(packs: &[ItemPack<'_>]) -> BTreeMap> {
     let mut faults: BTreeMap> = BTreeMap::new();
     let mut fault = |pack: &str, message: String| {
@@ -102,9 +102,9 @@ pub fn item_pack_faults(packs: &[ItemPack<'_>]) -> BTreeMap>
     }
 
     if let Some(base) = packs.iter().find(|pack| pack.id == BASE_MOD_ID) {
-        for (role, _) in ITEM_ROLES {
-            if !base.items.iter().any(|item| item.id.as_str() == role) {
-                fault(BASE_MOD_ID, format!("item '{role}' is not defined"));
+        for role in ItemRoleType::ALL {
+            if !base.items.iter().any(|item| item.id.as_str() == role.id()) {
+                fault(BASE_MOD_ID, format!("item '{}' is not defined", role.id()));
             }
         }
     }
@@ -133,11 +133,12 @@ fn item_faults(item: &ItemDesign) -> Vec {
             item.bid_cr, item.ask_cr
         ));
     }
-    if let Some((_, category)) = ITEM_ROLES.iter().find(|(role, _)| *role == id) {
-        if item.category != *category {
+    if let Some(role) = ItemRoleType::ALL.iter().find(|role| role.id() == id) {
+        if item.category != role.category() {
             faults.push(format!(
-                "item '{id}' is filed under {:?}; the game uses it as {category:?}",
-                item.category
+                "item '{id}' is filed under {:?}; the game uses it as {:?}",
+                item.category,
+                role.category()
             ));
         }
     }
@@ -145,7 +146,7 @@ fn item_faults(item: &ItemDesign) -> Vec {
     let mined = ASTEROID_KINDS
         .iter()
         .filter_map(|kind| ore_for_asteroid_kind(&AsteroidKindId::from(*kind)))
-        .any(|ore| ore.as_str() == id);
+        .any(|ore| ore.id() == id);
     if mined && item.mass_g > CARGO_CANISTER_MAX_MASS_G {
         faults.push(format!(
             "item '{id}' has a mass_g of {}; mined ore must fit one {CARGO_CANISTER_MAX_MASS_G} g \
@@ -163,10 +164,10 @@ mod tests {
     use super::*;
 
     fn item(id: &str, mass_g: u32) -> ItemDesign {
-        let category = ITEM_ROLES
+        let category = ItemRoleType::ALL
             .iter()
-            .find(|(role, _)| *role == id)
-            .map_or(ItemCategoryType::Parts, |(_, category)| *category);
+            .find(|role| role.id() == id)
+            .map_or(ItemCategoryType::Parts, |role| role.category());
         ItemDesign {
             id: ItemDesignId::from(id),
             name: id.to_string(),
@@ -179,9 +180,9 @@ mod tests {
     }
 
     fn base_items() -> Vec {
-        ITEM_ROLES
+        ItemRoleType::ALL
             .iter()
-            .map(|(role, _)| item(role, 1_000))
+            .map(|role| item(role.id(), 1_000))
             .collect()
     }
 
@@ -229,12 +230,12 @@ mod tests {
     #[test]
     fn a_dependent_pack_may_replace_an_item_and_a_cycle_may_not() {
         let base = base_items();
-        let mut rations = item(nova_gameplay::prelude::ITEM_RATIONS, 3_000);
+        let mut rations = item(ItemRoleType::Rations.id(), 3_000);
         rations.ask_cr = 20;
         rations.bid_cr = 15;
         let plate = [
-            item(nova_gameplay::prelude::ITEM_HULL_PLATE, 12_000),
-            item(nova_gameplay::prelude::ITEM_SALVAGED_PARTS, 30_000),
+            item(ItemRoleType::HullPlate.id(), 12_000),
+            item(ItemRoleType::SalvagedParts.id(), 30_000),
             rations.clone(),
         ];
         let core = [item("deep-salvage/reactor_core", 80_000)];
@@ -281,20 +282,16 @@ mod tests {
     fn the_base_game_keeps_every_role_and_no_pack_repeats_or_weightless_items() {
         let mut base = base_items();
         base.retain(|item| {
-            ![
-                nova_gameplay::prelude::ITEM_TORPEDO,
-                nova_gameplay::prelude::ITEM_SALVAGED_PARTS,
-            ]
-            .contains(&item.id.as_str())
+            ![ItemRoleType::Torpedo, ItemRoleType::SalvagedParts]
+                .iter()
+                .any(|role| role.id() == item.id.as_str())
         });
         base[0].category = ItemCategoryType::Food;
         for item in &mut base {
-            match item.id.as_str() {
-                nova_gameplay::prelude::ITEM_STONE_ORE => {
-                    item.mass_g = CARGO_CANISTER_MAX_MASS_G + 1;
-                }
-                nova_gameplay::prelude::ITEM_RATIONS => item.category = ItemCategoryType::Parts,
-                _ => {}
+            if item.id.as_str() == ItemRoleType::StoneOre.id() {
+                item.mass_g = CARGO_CANISTER_MAX_MASS_G + 1;
+            } else if item.id.as_str() == ItemRoleType::Rations.id() {
+                item.category = ItemCategoryType::Parts;
             }
         }
         base.push(item("Scrap", 0));
diff --git a/crates/nova_authoring/src/base_content/items.rs b/crates/nova_authoring/src/base_content/items.rs
index 8f3aedb3b..653be469a 100644
--- a/crates/nova_authoring/src/base_content/items.rs
+++ b/crates/nova_authoring/src/base_content/items.rs
@@ -6,26 +6,22 @@
 //! mod adds items under its own ids, and a mod that depends on another pack
 //! may replace that pack's whole item.
 //!
-//! The ids of [`ITEM_ROLES`](nova_gameplay::prelude::ITEM_ROLES) are the ones
-//! the game itself uses - weapons reload from them, repair spends one, mining
-//! yields them, generated sector ship holds draw them - so the base game must
-//! define each under its role's category. A dependent mod may replace one of
+//! The ids of [`ItemRoleType`](nova_gameplay::prelude::ItemRoleType) are the
+//! ones the game itself uses - weapons reload from them, repair spends one,
+//! mining yields them, generated sector ship holds draw them - so the base
+//! game must define each under its role's category. A dependent mod may replace one of
 //! them whole, with new mass and prices, under the same category.
 //! Prices are provisional values, not a balance decision; each bid is below
 //! its ask, so buying and selling back loses credits.
 
-use nova_gameplay::prelude::{
-    ItemCategoryType, ItemDesign, ItemDesignId, ITEM_CARBON_ORE, ITEM_HULL_PLATE, ITEM_IRON_ORE,
-    ITEM_PDC_ROUND, ITEM_RAIL_SLUG, ITEM_RATIONS, ITEM_SALVAGED_PARTS, ITEM_STONE_ORE,
-    ITEM_TORPEDO, ITEM_WATER_ICE,
-};
+use nova_gameplay::prelude::{ItemCategoryType, ItemDesign, ItemDesignId, ItemRoleType};
 
 /// The base game's items, in the order the file carries them.
 pub(crate) fn item_catalog() -> Vec {
     use ItemCategoryType::{Ammo, Food, Parts, Raw, Repair};
     [
         (
-            ITEM_HULL_PLATE,
+            ItemRoleType::HullPlate,
             "Hull plate",
             "Structural plating for hull sections.",
             Repair,
@@ -34,7 +30,7 @@ pub(crate) fn item_catalog() -> Vec {
             30,
         ),
         (
-            ITEM_PDC_ROUND,
+            ItemRoleType::PdcRound,
             "PDC round",
             "Point-defense round. Kinetic and Pierce mounts reload from it.",
             Ammo,
@@ -43,7 +39,7 @@ pub(crate) fn item_catalog() -> Vec {
             3,
         ),
         (
-            ITEM_RAIL_SLUG,
+            ItemRoleType::RailSlug,
             "Rail slug",
             "Railgun slug. Railgun mounts reload from it.",
             Ammo,
@@ -52,7 +48,7 @@ pub(crate) fn item_catalog() -> Vec {
             30,
         ),
         (
-            ITEM_TORPEDO,
+            ItemRoleType::Torpedo,
             "Torpedo",
             "Torpedo. Every torpedo bay reloads from it.",
             Ammo,
@@ -61,7 +57,7 @@ pub(crate) fn item_catalog() -> Vec {
             300,
         ),
         (
-            ITEM_STONE_ORE,
+            ItemRoleType::StoneOre,
             "Stone ore",
             "Silicate rock, mined from rock asteroids.",
             Raw,
@@ -70,7 +66,7 @@ pub(crate) fn item_catalog() -> Vec {
             3,
         ),
         (
-            ITEM_IRON_ORE,
+            ItemRoleType::IronOre,
             "Iron ore",
             "Metal-rich ore, mined from metal asteroids.",
             Raw,
@@ -79,7 +75,7 @@ pub(crate) fn item_catalog() -> Vec {
             12,
         ),
         (
-            ITEM_WATER_ICE,
+            ItemRoleType::WaterIce,
             "Water ice",
             "Frozen volatiles, mined from ice asteroids.",
             Raw,
@@ -88,7 +84,7 @@ pub(crate) fn item_catalog() -> Vec {
             9,
         ),
         (
-            ITEM_CARBON_ORE,
+            ItemRoleType::CarbonOre,
             "Carbon ore",
             "Carbon-rich ore, mined from carbon asteroids.",
             Raw,
@@ -97,7 +93,7 @@ pub(crate) fn item_catalog() -> Vec {
             6,
         ),
         (
-            ITEM_RATIONS,
+            ItemRoleType::Rations,
             "Rations",
             "Packed crew food. Traders buy and sell it.",
             Food,
@@ -106,7 +102,7 @@ pub(crate) fn item_catalog() -> Vec {
             6,
         ),
         (
-            ITEM_SALVAGED_PARTS,
+            ItemRoleType::SalvagedParts,
             "Salvaged parts",
             "Reusable ship components. Traders buy and sell them.",
             Parts,
diff --git a/crates/nova_authoring/src/base_content/scenarios/main_menu/duel.rs b/crates/nova_authoring/src/base_content/scenarios/main_menu/duel.rs
index f3346bbf1..324be839f 100644
--- a/crates/nova_authoring/src/base_content/scenarios/main_menu/duel.rs
+++ b/crates/nova_authoring/src/base_content/scenarios/main_menu/duel.rs
@@ -259,7 +259,7 @@ fn duelist(
             lootable: false,
             // Equal reserves keep the duel even: each magazine refills from
             // its own hold, and a dry duelist fights on with what is loaded.
-            inventory: ShipInventoryStock::new([(ITEM_PDC_ROUND.into(), 2_000)]),
+            inventory: ShipInventoryStock::new([(ItemRoleType::PdcRound.into(), 2_000)]),
             credits: 2_000,
             ..Default::default()
         }),
diff --git a/crates/nova_authoring/src/base_content/scenarios/open_world.rs b/crates/nova_authoring/src/base_content/scenarios/open_world.rs
index e3efbba5c..ce167a0ec 100644
--- a/crates/nova_authoring/src/base_content/scenarios/open_world.rs
+++ b/crates/nova_authoring/src/base_content/scenarios/open_world.rs
@@ -12,9 +12,7 @@ use std::collections::BTreeMap;
 
 use bevy::prelude::*;
 use nova_events::prelude::*;
-use nova_gameplay::prelude::{
-    ShipInventoryStock, ITEM_HULL_PLATE, ITEM_PDC_ROUND, ITEM_RAIL_SLUG, ITEM_TORPEDO,
-};
+use nova_gameplay::prelude::{ItemRoleType, ShipInventoryStock};
 use nova_input::prelude::InputSource;
 use nova_scenario::prelude::*;
 use nova_ship::prelude::ShipCapabilities;
@@ -92,10 +90,10 @@ fn player() -> ScenarioObjectConfig {
             // Plates for repair and a finite reserve for every idle reload:
             // point defense, railgun and torpedo bays. 3,520 kg in all.
             inventory: ShipInventoryStock::new([
-                (ITEM_HULL_PLATE.into(), 12),
-                (ITEM_PDC_ROUND.into(), 6000),
-                (ITEM_RAIL_SLUG.into(), 20),
-                (ITEM_TORPEDO.into(), 12),
+                (ItemRoleType::HullPlate.into(), 12),
+                (ItemRoleType::PdcRound.into(), 6000),
+                (ItemRoleType::RailSlug.into(), 20),
+                (ItemRoleType::Torpedo.into(), 12),
             ]),
             lootable: false,
             credits: 2_000,
diff --git a/crates/nova_authoring/src/base_content/scenarios/tutorial/range.rs b/crates/nova_authoring/src/base_content/scenarios/tutorial/range.rs
index 66bf5c9f5..9bab13008 100644
--- a/crates/nova_authoring/src/base_content/scenarios/tutorial/range.rs
+++ b/crates/nova_authoring/src/base_content/scenarios/tutorial/range.rs
@@ -139,7 +139,7 @@ pub(crate) fn trainer_with(capabilities: ShipCapabilities) -> ScenarioObjectConf
             // `PLANETOID_MASS`): an intentional stationary start.
             initial_velocity: MetersPerSecond3::ZERO,
             // The gun's idle reload draws on this finite reserve.
-            inventory: ShipInventoryStock::new([(ITEM_PDC_ROUND.into(), 2000)]),
+            inventory: ShipInventoryStock::new([(ItemRoleType::PdcRound.into(), 2000)]),
             lootable: false,
             credits: 2_000,
         }),
diff --git a/crates/nova_console/src/cheats.rs b/crates/nova_console/src/cheats.rs
index b206dd752..aa6d0cd04 100644
--- a/crates/nova_console/src/cheats.rs
+++ b/crates/nova_console/src/cheats.rs
@@ -332,7 +332,11 @@ mod tests {
             .spawn((
                 SpaceshipRootMarker,
                 EntityId("player_spaceship".to_string()),
-                ShipInventory::new(&items, 400_000, [(ItemDesignId::from(ITEM_HULL_PLATE), 12)]),
+                ShipInventory::new(
+                    &items,
+                    400_000,
+                    [(ItemDesignId::from(ItemRoleType::HullPlate), 12)],
+                ),
             ))
             .id();
         world.insert_resource(items);
@@ -374,10 +378,10 @@ mod tests {
         assert_eq!(
             hold.stacks().collect::>(),
             [
-                (&ItemDesignId::from(ITEM_HULL_PLATE), 17),
-                (&ItemDesignId::from(ITEM_PDC_ROUND), 300),
-                (&ItemDesignId::from(ITEM_RAIL_SLUG), 1),
-                (&ItemDesignId::from(ITEM_TORPEDO), 1),
+                (&ItemDesignId::from(ItemRoleType::HullPlate), 17),
+                (&ItemDesignId::from(ItemRoleType::PdcRound), 300),
+                (&ItemDesignId::from(ItemRoleType::RailSlug), 1),
+                (&ItemDesignId::from(ItemRoleType::Torpedo), 1),
             ]
         );
         assert_eq!(hold.used_g(&items), 400_000);
diff --git a/crates/nova_gameplay/src/inventory.rs b/crates/nova_gameplay/src/inventory.rs
index 63b0f6203..be677bbca 100644
--- a/crates/nova_gameplay/src/inventory.rs
+++ b/crates/nova_gameplay/src/inventory.rs
@@ -4,7 +4,7 @@
 //! Items are content. Each [`ItemDesign`] is authored in RON and keyed by an
 //! [`ItemDesignId`]; the mod merge publishes the effective catalog as
 //! [`GameItems`], and every mass, price and name comes from it. Engine code
-//! names only the role items in [`ITEM_ROLES`], which content lint and the
+//! names only the role items of [`ItemRoleType`], which content lint and the
 //! merge require with their categories.
 //!
 //! Every ship root requires a [`ShipInventory`]. Its `Default` has no room and
@@ -12,16 +12,16 @@
 //! An authored ship states its stock as a [`ShipInventoryStock`] through
 //! `SpaceshipConfig::inventory`, which RON requires; the spawn consumes that
 //! stock into a `ShipInventory` whose capacity its design derives. A Ship pane
-//! repair spends [`ITEM_HULL_PLATE`] by the [`plan_plate_repair`] rule, an
-//! Inventory pane transfer moves items between two docked ships by the
-//! [`plan_item_transfer`] rule, and a jettison queues [`CargoCanister`]s by the
-//! [`plan_item_jettison`] rule, a weapon's idle reload moves its ammunition
-//! item into the magazine, and a Buy or Sell with a docked trader moves items
-//! and [`ShipCredits`] together by the [`plan_item_trade`] rule. A saved
-//! world keeps stock and credits across a Load; any other scenario returns
-//! them to their authored values when it loads again. A stack exists only
-//! while its count is above zero, and the mass of all stacks never passes the
-//! capacity. Mass is counted in grams; [`kg_text`] shows it in kilograms.
+//! repair spends [`ItemRoleType::HullPlate`] by the [`plan_plate_repair`]
+//! rule, an Inventory pane transfer moves items between two docked ships by
+//! the [`plan_item_transfer`] rule, and a jettison queues [`CargoCanister`]s
+//! by the [`plan_item_jettison`] rule, a weapon's idle reload moves its
+//! ammunition item into the magazine, and a Buy or Sell with a docked trader
+//! moves items and [`ShipCredits`] together by the [`plan_item_trade`] rule.
+//! A saved world keeps stock and credits across a Load; any other scenario
+//! returns them to their authored values when it loads again. A stack exists
+//! only while its count is above zero, and the mass of all stacks never passes
+//! the capacity. Mass is counted in grams; [`kg_text`] shows it in kilograms.
 
 use std::{collections::BTreeMap, sync::Arc};
 
@@ -35,12 +35,10 @@ pub mod prelude {
         kg_text, plan_credit_take, plan_item_jettison, plan_item_trade, plan_item_transfer,
         plan_plate_repair, CargoCanister, CargoCanisterIdAllocator, CargoCanisterRuntimeId,
         CreditTakeRefusalType, GameItems, ItemCategoryType, ItemDesign, ItemDesignId, ItemJettison,
-        ItemJettisonRefusalType, ItemTrade, ItemTradeRefusalType, ItemTradeType,
+        ItemJettisonRefusalType, ItemRoleType, ItemTrade, ItemTradeRefusalType, ItemTradeType,
         ItemTransferRefusalType, ItemTransferType, LootableShipMarker, PlateRepair,
         PlateRepairRefusalType, ShipCredits, ShipInventory, ShipInventoryStock,
-        CARGO_CANISTER_MAX_MASS_G, HULL_PLATE_HEALTH, ITEM_CARBON_ORE, ITEM_HULL_PLATE,
-        ITEM_IRON_ORE, ITEM_PDC_ROUND, ITEM_RAIL_SLUG, ITEM_RATIONS, ITEM_ROLES,
-        ITEM_SALVAGED_PARTS, ITEM_STONE_ORE, ITEM_TORPEDO, ITEM_WATER_ICE,
+        CARGO_CANISTER_MAX_MASS_G, HULL_PLATE_HEALTH,
     };
 }
 
@@ -69,52 +67,104 @@ impl From<&str> for ItemDesignId {
     }
 }
 
+/// The derived order and hash are the id string's, so a catalog keyed by id
+/// answers a `&str` lookup without allocating one.
+impl std::borrow::Borrow for ItemDesignId {
+    fn borrow(&self) -> &str {
+        &self.0
+    }
+}
+
 impl std::fmt::Display for ItemDesignId {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.write_str(&self.0)
     }
 }
 
-/// Hull plating stock, counted in plates. The repair item.
-pub const ITEM_HULL_PLATE: &str = "HullPlate";
-/// One point-defense round. Kinetic and Pierce mounts load the same round;
-/// the mount decides its damage type.
-pub const ITEM_PDC_ROUND: &str = "PdcRound";
-/// One railgun slug.
-pub const ITEM_RAIL_SLUG: &str = "RailSlug";
-/// One torpedo. Every bay type loads the same torpedo; the bay decides its
-/// flight.
-pub const ITEM_TORPEDO: &str = "Torpedo";
-/// Ore mined from a rock-kind asteroid.
-pub const ITEM_STONE_ORE: &str = "StoneOre";
-/// Ore mined from a metal-kind asteroid.
-pub const ITEM_IRON_ORE: &str = "IronOre";
-/// Ice mined from an ice-kind asteroid.
-pub const ITEM_WATER_ICE: &str = "WaterIce";
-/// Ore mined from a carbon-kind asteroid.
-pub const ITEM_CARBON_ORE: &str = "CarbonOre";
-/// Packed crew food. Generated sector ship holds draw it.
-pub const ITEM_RATIONS: &str = "Rations";
-/// Reusable ship components. Generated sector ship holds draw them.
-pub const ITEM_SALVAGED_PARTS: &str = "SalvagedParts";
-
 /// Every item engine code names, with the category it must keep. Content lint
 /// and the merge refuse a catalog without one of these ids and a definition
 /// that files one under another category: the repair, the weapon reloads,
 /// the mining yield and the sector hold draws would otherwise spend or make
-/// something else.
-pub const ITEM_ROLES: [(&str, ItemCategoryType); 10] = [
-    (ITEM_HULL_PLATE, ItemCategoryType::Repair),
-    (ITEM_PDC_ROUND, ItemCategoryType::Ammo),
-    (ITEM_RAIL_SLUG, ItemCategoryType::Ammo),
-    (ITEM_TORPEDO, ItemCategoryType::Ammo),
-    (ITEM_STONE_ORE, ItemCategoryType::Raw),
-    (ITEM_IRON_ORE, ItemCategoryType::Raw),
-    (ITEM_WATER_ICE, ItemCategoryType::Raw),
-    (ITEM_CARBON_ORE, ItemCategoryType::Raw),
-    (ITEM_RATIONS, ItemCategoryType::Food),
-    (ITEM_SALVAGED_PARTS, ItemCategoryType::Parts),
-];
+/// something else. A closed enum, so a match over the roles is exhaustive
+/// where a match over id strings is not.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum ItemRoleType {
+    /// Hull plating stock, counted in plates. The repair item.
+    HullPlate,
+    /// One point-defense round. Kinetic and Pierce mounts load the same round;
+    /// the mount decides its damage type.
+    PdcRound,
+    /// One railgun slug.
+    RailSlug,
+    /// One torpedo. Every bay type loads the same torpedo; the bay decides its
+    /// flight.
+    Torpedo,
+    /// Ore mined from a rock-kind asteroid.
+    StoneOre,
+    /// Ore mined from a metal-kind asteroid.
+    IronOre,
+    /// Ice mined from an ice-kind asteroid.
+    WaterIce,
+    /// Ore mined from a carbon-kind asteroid.
+    CarbonOre,
+    /// Packed crew food. Generated sector ship holds draw it.
+    Rations,
+    /// Reusable ship components. Generated sector ship holds draw them.
+    SalvagedParts,
+}
+
+impl ItemRoleType {
+    /// Every role, in base catalog order.
+    pub const ALL: [Self; 10] = [
+        Self::HullPlate,
+        Self::PdcRound,
+        Self::RailSlug,
+        Self::Torpedo,
+        Self::StoneOre,
+        Self::IronOre,
+        Self::WaterIce,
+        Self::CarbonOre,
+        Self::Rations,
+        Self::SalvagedParts,
+    ];
+
+    /// The authored catalog id the base game defines this role under.
+    pub const fn id(self) -> &'static str {
+        match self {
+            Self::HullPlate => "HullPlate",
+            Self::PdcRound => "PdcRound",
+            Self::RailSlug => "RailSlug",
+            Self::Torpedo => "Torpedo",
+            Self::StoneOre => "StoneOre",
+            Self::IronOre => "IronOre",
+            Self::WaterIce => "WaterIce",
+            Self::CarbonOre => "CarbonOre",
+            Self::Rations => "Rations",
+            Self::SalvagedParts => "SalvagedParts",
+        }
+    }
+
+    /// The category the item under [`id`](Self::id) must keep.
+    pub const fn category(self) -> ItemCategoryType {
+        match self {
+            Self::HullPlate => ItemCategoryType::Repair,
+            Self::PdcRound | Self::RailSlug | Self::Torpedo => ItemCategoryType::Ammo,
+            Self::StoneOre | Self::IronOre | Self::WaterIce | Self::CarbonOre => {
+                ItemCategoryType::Raw
+            }
+            Self::Rations => ItemCategoryType::Food,
+            Self::SalvagedParts => ItemCategoryType::Parts,
+        }
+    }
+}
+
+impl From for ItemDesignId {
+    /// A fresh shared id. A path that runs per pulse or per spawn clones the
+    /// id of [`GameItems::role`] instead of allocating one.
+    fn from(role: ItemRoleType) -> Self {
+        Self::from(role.id())
+    }
+}
 
 /// One authored item: what it is called, what it is for, what it weighs and
 /// what a trader pays for it. Every field is required.
@@ -185,6 +235,22 @@ impl GameItems {
             .unwrap_or_else(|| panic!("item '{id}' is not in the loaded item catalog"))
     }
 
+    /// The item filling `role`, for a catalog content lint or the merge
+    /// already checked.
+    ///
+    /// # Panics
+    ///
+    /// When the catalog lacks the role's id: the catalog skipped
+    /// `item_pack_faults`, which refuses a base pack missing a role.
+    pub fn role(&self, role: ItemRoleType) -> &ItemDesign {
+        self.0.get(role.id()).unwrap_or_else(|| {
+            panic!(
+                "role item {role:?} ('{}') is not in the loaded item catalog",
+                role.id()
+            )
+        })
+    }
+
     /// Every item, in id order.
     pub fn iter(&self) -> impl Iterator {
         self.0.values()
@@ -930,13 +996,13 @@ mod tests {
     fn kg_text_shows_grams_as_kilograms_without_trailing_zeros() {
         assert_eq!(kg_text(0), "0 kg");
         let items = crate::test_support::test_items();
-        let mass_g = |id: &str| u64::from(items.design(&ItemDesignId::from(id)).mass_g);
-        assert_eq!(kg_text(mass_g(ITEM_PDC_ROUND)), "0.2 kg");
-        assert_eq!(kg_text(mass_g(ITEM_HULL_PLATE)), "10 kg");
+        let mass_g = |role: ItemRoleType| u64::from(items.role(role).mass_g);
+        assert_eq!(kg_text(mass_g(ItemRoleType::PdcRound)), "0.2 kg");
+        assert_eq!(kg_text(mass_g(ItemRoleType::HullPlate)), "10 kg");
         assert_eq!(kg_text(1_050), "1.05 kg");
         assert_eq!(kg_text(3_520_001), "3520.001 kg");
         // 6000 PDC rounds weigh exactly 1200 kg: no float drift in the sum.
-        let rounds = ShipInventory::new(&items, 1_200_000, [(ITEM_PDC_ROUND.into(), 6000)]);
+        let rounds = ShipInventory::new(&items, 1_200_000, [(ItemRoleType::PdcRound.into(), 6000)]);
         assert_eq!(rounds.used_g(&items), 1_200_000);
         assert_eq!(rounds.free_g(&items), 0);
     }
@@ -1009,14 +1075,15 @@ mod transfer_tests {
         use ItemTransferRefusalType::*;
         use ItemTransferType::*;
         // 400 kg holds 40 plates of 10 kg.
-        let plates =
-            |count: u32| ShipInventory::new(&items, 400_000, [(ITEM_HULL_PLATE.into(), count)]);
+        let plates = |count: u32| {
+            ShipInventory::new(&items, 400_000, [(ItemRoleType::HullPlate.into(), count)])
+        };
         let plan = |transfer, lootable, quantity, own: &ShipInventory, partner: &ShipInventory| {
             plan_item_transfer(
                 &items,
                 transfer,
                 lootable,
-                &ItemDesignId::from(ITEM_HULL_PLATE),
+                &ItemDesignId::from(ItemRoleType::HullPlate),
                 quantity,
                 own,
                 partner,
@@ -1063,9 +1130,9 @@ mod transfer_tests {
         // A planned move conserves the total and empties a drained stack.
         let (mut own, mut partner) = (own, partner);
         let moved = plan(Take, true, Some(8), &own, &partner).expect("planned");
-        partner.remove(&ItemDesignId::from(ITEM_HULL_PLATE), moved);
-        own.add(&items, &ItemDesignId::from(ITEM_HULL_PLATE), moved);
-        assert_eq!(own.count(&ItemDesignId::from(ITEM_HULL_PLATE)), 20);
+        partner.remove(&ItemDesignId::from(ItemRoleType::HullPlate), moved);
+        own.add(&items, &ItemDesignId::from(ItemRoleType::HullPlate), moved);
+        assert_eq!(own.count(&ItemDesignId::from(ItemRoleType::HullPlate)), 20);
         assert_eq!(own.used_g(&items), 200_000);
         assert_eq!(own.free_g(&items), 200_000);
         assert!(partner.is_empty());
@@ -1077,8 +1144,9 @@ mod transfer_tests {
         use ItemTradeRefusalType::*;
         use ItemTradeType::*;
         // 400 kg of room; ore is 10 kg, ask 4 and bid 3 for stone.
-        let ore =
-            |count: u32| ShipInventory::new(&items, 400_000, [(ITEM_STONE_ORE.into(), count)]);
+        let ore = |count: u32| {
+            ShipInventory::new(&items, 400_000, [(ItemRoleType::StoneOre.into(), count)])
+        };
         let plan = |trade,
                     trades,
                     quantity,
@@ -1090,7 +1158,7 @@ mod transfer_tests {
                 &items,
                 trade,
                 trades,
-                &ItemDesignId::from(ITEM_STONE_ORE),
+                &ItemDesignId::from(ItemRoleType::StoneOre),
                 quantity,
                 own,
                 own_cr,
@@ -1141,14 +1209,14 @@ mod transfer_tests {
             plan(Buy, true, Some(5), &exact, 19, &seller, 0),
             Err(NoCredits { credits: 19 })
         );
-        seller.remove(&ItemDesignId::from(ITEM_STONE_ORE), 5);
-        exact.add(&items, &ItemDesignId::from(ITEM_STONE_ORE), 5);
+        seller.remove(&ItemDesignId::from(ItemRoleType::StoneOre), 5);
+        exact.add(&items, &ItemDesignId::from(ItemRoleType::StoneOre), 5);
         buyer_cr -= 20;
         seller_cr += 20;
         assert_eq!((buyer_cr, seller_cr, exact.free_g(&items)), (0, 20, 0));
         assert_eq!(
-            exact.count(&ItemDesignId::from(ITEM_STONE_ORE))
-                + seller.count(&ItemDesignId::from(ITEM_STONE_ORE)),
+            exact.count(&ItemDesignId::from(ItemRoleType::StoneOre))
+                + seller.count(&ItemDesignId::from(ItemRoleType::StoneOre)),
             5
         );
         assert_eq!(
@@ -1223,13 +1291,17 @@ mod transfer_tests {
         let (mut own, mut partner) = (own, partner);
         let (mut own_cr, mut partner_cr) = (100u32, 7u32);
         let trade = plan(Buy, true, Some(5), &own, own_cr, &partner, partner_cr).expect("planned");
-        partner.remove(&ItemDesignId::from(ITEM_STONE_ORE), trade.count);
-        own.add(&items, &ItemDesignId::from(ITEM_STONE_ORE), trade.count);
+        partner.remove(&ItemDesignId::from(ItemRoleType::StoneOre), trade.count);
+        own.add(
+            &items,
+            &ItemDesignId::from(ItemRoleType::StoneOre),
+            trade.count,
+        );
         own_cr -= trade.price_cr;
         partner_cr += trade.price_cr;
         assert_eq!(
-            own.count(&ItemDesignId::from(ITEM_STONE_ORE))
-                + partner.count(&ItemDesignId::from(ITEM_STONE_ORE)),
+            own.count(&ItemDesignId::from(ItemRoleType::StoneOre))
+                + partner.count(&ItemDesignId::from(ItemRoleType::StoneOre)),
             40
         );
         assert_eq!((own_cr, partner_cr), (80, 27));
@@ -1260,15 +1332,15 @@ mod transfer_tests {
     fn item_jettison_plans_refuse_in_order() {
         let items = crate::test_support::test_items();
         use ItemJettisonRefusalType::*;
-        let own = ShipInventory::new(&items, 400_000, [(ITEM_HULL_PLATE.into(), 12)]);
-        let tail = CargoCanister::new(&items, &ItemDesignId::from(ITEM_HULL_PLATE), 9);
+        let own = ShipInventory::new(&items, 400_000, [(ItemRoleType::HullPlate.into(), 12)]);
+        let tail = CargoCanister::new(&items, &ItemDesignId::from(ItemRoleType::HullPlate), 9);
         let plan = |docked, has_intake, tail: Option<&CargoCanister>, quantity| {
             plan_item_jettison(
                 &items,
                 docked,
                 has_intake,
                 tail,
-                &ItemDesignId::from(ITEM_HULL_PLATE),
+                &ItemDesignId::from(ItemRoleType::HullPlate),
                 quantity,
                 &own,
             )
@@ -1290,10 +1362,10 @@ mod transfer_tests {
             &items,
             10_000_000,
             [
-                (ITEM_HULL_PLATE.into(), 12),
-                (ITEM_PDC_ROUND.into(), 2_500),
-                (ITEM_RAIL_SLUG.into(), 25),
-                (ITEM_TORPEDO.into(), 3),
+                (ItemRoleType::HullPlate.into(), 12),
+                (ItemRoleType::PdcRound.into(), 2_500),
+                (ItemRoleType::RailSlug.into(), 25),
+                (ItemRoleType::Torpedo.into(), 3),
             ],
         );
         let plan = |tail: Option<&CargoCanister>, item, quantity| {
@@ -1322,41 +1394,47 @@ mod transfer_tests {
         };
 
         // One 150 kg torpedo per canister; 10 slugs and 1000 rounds fill one.
-        let torpedoes = plan(None, ITEM_TORPEDO, 3);
+        let torpedoes = plan(None, ItemRoleType::Torpedo, 3);
         assert_eq!((torpedoes.count, torpedoes.merged), (3, 0));
         assert_eq!(
             split(&torpedoes),
-            vec![vec![(ITEM_TORPEDO.to_string(), 1)]; 3]
+            vec![vec![(ItemRoleType::Torpedo.id().to_string(), 1)]; 3]
         );
-        let slugs = plan(None, ITEM_RAIL_SLUG, 25);
+        let slugs = plan(None, ItemRoleType::RailSlug, 25);
         assert_eq!(
             split(&slugs),
             [
-                vec![(ITEM_RAIL_SLUG.to_string(), 10)],
-                vec![(ITEM_RAIL_SLUG.to_string(), 10)],
-                vec![(ITEM_RAIL_SLUG.to_string(), 5)],
+                vec![(ItemRoleType::RailSlug.id().to_string(), 10)],
+                vec![(ItemRoleType::RailSlug.id().to_string(), 10)],
+                vec![(ItemRoleType::RailSlug.id().to_string(), 5)],
             ]
         );
-        let rounds = plan(None, ITEM_PDC_ROUND, 2_500);
+        let rounds = plan(None, ItemRoleType::PdcRound, 2_500);
         assert_eq!(
             split(&rounds),
             [
-                vec![(ITEM_PDC_ROUND.to_string(), 1_000)],
-                vec![(ITEM_PDC_ROUND.to_string(), 1_000)],
-                vec![(ITEM_PDC_ROUND.to_string(), 500)],
+                vec![(ItemRoleType::PdcRound.id().to_string(), 1_000)],
+                vec![(ItemRoleType::PdcRound.id().to_string(), 1_000)],
+                vec![(ItemRoleType::PdcRound.id().to_string(), 500)],
             ]
         );
 
         // A waiting 90 kg tail has 110 kg of room: 5 slugs merge ...
-        let tail = CargoCanister::new(&items, &ItemDesignId::from(ITEM_HULL_PLATE), 9);
-        let into_tail = plan(Some(&tail), ITEM_RAIL_SLUG, 7);
+        let tail = CargoCanister::new(&items, &ItemDesignId::from(ItemRoleType::HullPlate), 9);
+        let into_tail = plan(Some(&tail), ItemRoleType::RailSlug, 7);
         assert_eq!((into_tail.count, into_tail.merged), (7, 5));
-        assert_eq!(split(&into_tail), [vec![(ITEM_RAIL_SLUG.to_string(), 2)]]);
+        assert_eq!(
+            split(&into_tail),
+            [vec![(ItemRoleType::RailSlug.id().to_string(), 2)]]
+        );
         // ... and no torpedo, which starts a new canister.
-        let past_tail = plan(Some(&tail), ITEM_TORPEDO, 1);
+        let past_tail = plan(Some(&tail), ItemRoleType::Torpedo, 1);
         assert_eq!(past_tail.merged, 0);
-        assert_eq!(split(&past_tail), [vec![(ITEM_TORPEDO.to_string(), 1)]]);
-        let fits = plan(Some(&tail), ITEM_HULL_PLATE, 11);
+        assert_eq!(
+            split(&past_tail),
+            [vec![(ItemRoleType::Torpedo.id().to_string(), 1)]]
+        );
+        let fits = plan(Some(&tail), ItemRoleType::HullPlate, 11);
         assert_eq!((fits.merged, fits.canisters.len()), (11, 0));
 
         // Every planned count is conserved across the tail and the canisters.
@@ -1383,7 +1461,7 @@ mod serde_tests {
             ron::from_str(r#"{"HullPlate": 12}"#).expect("valid stock parses");
         assert_eq!(
             ok.stacks().collect::>(),
-            [(&ItemDesignId::from(ITEM_HULL_PLATE), 12)]
+            [(&ItemDesignId::from(ItemRoleType::HullPlate), 12)]
         );
         assert_eq!(ok.mass_g(&items), 120_000);
         let round_trip: ShipInventoryStock =
diff --git a/crates/nova_gameplay/src/test_support.rs b/crates/nova_gameplay/src/test_support.rs
index e14d0618f..ba5c55ef7 100644
--- a/crates/nova_gameplay/src/test_support.rs
+++ b/crates/nova_gameplay/src/test_support.rs
@@ -77,8 +77,8 @@ pub fn settle(app: &mut App) {
 /// mass or a price reads it through this catalog, never as a literal.
 pub fn test_items() -> crate::inventory::GameItems {
     use crate::inventory::{ItemCategoryType::*, *};
-    let item = |id: &str, name: &str, category, mass_g, ask_cr, bid_cr| ItemDesign {
-        id: ItemDesignId::from(id),
+    let item = |role: ItemRoleType, name: &str, category, mass_g, ask_cr, bid_cr| ItemDesign {
+        id: ItemDesignId::from(role),
         name: name.to_string(),
         about: format!("{name} for tests."),
         category,
@@ -87,17 +87,24 @@ pub fn test_items() -> crate::inventory::GameItems {
         bid_cr,
     };
     GameItems::new([
-        item(ITEM_HULL_PLATE, "Hull plate", Repair, 10_000, 40, 30),
-        item(ITEM_PDC_ROUND, "PDC round", Ammo, 200, 4, 3),
-        item(ITEM_RAIL_SLUG, "Rail slug", Ammo, 20_000, 40, 30),
-        item(ITEM_TORPEDO, "Torpedo", Ammo, 150_000, 400, 300),
-        item(ITEM_STONE_ORE, "Stone ore", Raw, 10_000, 4, 3),
-        item(ITEM_IRON_ORE, "Iron ore", Raw, 10_000, 16, 12),
-        item(ITEM_WATER_ICE, "Water ice", Raw, 10_000, 12, 9),
-        item(ITEM_CARBON_ORE, "Carbon ore", Raw, 10_000, 8, 6),
-        item(ITEM_RATIONS, "Rations", Food, 2_000, 8, 6),
         item(
-            ITEM_SALVAGED_PARTS,
+            ItemRoleType::HullPlate,
+            "Hull plate",
+            Repair,
+            10_000,
+            40,
+            30,
+        ),
+        item(ItemRoleType::PdcRound, "PDC round", Ammo, 200, 4, 3),
+        item(ItemRoleType::RailSlug, "Rail slug", Ammo, 20_000, 40, 30),
+        item(ItemRoleType::Torpedo, "Torpedo", Ammo, 150_000, 400, 300),
+        item(ItemRoleType::StoneOre, "Stone ore", Raw, 10_000, 4, 3),
+        item(ItemRoleType::IronOre, "Iron ore", Raw, 10_000, 16, 12),
+        item(ItemRoleType::WaterIce, "Water ice", Raw, 10_000, 12, 9),
+        item(ItemRoleType::CarbonOre, "Carbon ore", Raw, 10_000, 8, 6),
+        item(ItemRoleType::Rations, "Rations", Food, 2_000, 8, 6),
+        item(
+            ItemRoleType::SalvagedParts,
             "Salvaged parts",
             Parts,
             25_000,
diff --git a/crates/nova_hud/src/ammo_readout.rs b/crates/nova_hud/src/ammo_readout.rs
index 40044029f..aa24e49bc 100644
--- a/crates/nova_hud/src/ammo_readout.rs
+++ b/crates/nova_hud/src/ammo_readout.rs
@@ -1037,9 +1037,9 @@ mod tests {
                     &items,
                     10_000_000,
                     [
-                        (ITEM_PDC_ROUND.into(), 1000),
-                        (ITEM_RAIL_SLUG.into(), 10),
-                        (ITEM_TORPEDO.into(), 10),
+                        (ItemRoleType::PdcRound.into(), 1000),
+                        (ItemRoleType::RailSlug.into(), 10),
+                        (ItemRoleType::Torpedo.into(), 10),
                     ],
                 ),
             ))
@@ -1698,7 +1698,7 @@ mod tests {
             .rounds = 0;
         world
             .entity_mut(turret)
-            .insert(reload_at(ITEM_PDC_ROUND.into(), 3.0, 200, 0.5));
+            .insert(reload_at(ItemRoleType::PdcRound.into(), 3.0, 200, 0.5));
         world.run_system_once(sync_ammo_readouts).unwrap();
         world.run_system_once(drive_ammo_readouts).unwrap();
 
@@ -1715,7 +1715,7 @@ mod tests {
         world.entity_mut(player).insert(ShipInventory::new(
             &items,
             10_000_000,
-            [(ITEM_PDC_ROUND.into(), 50)],
+            [(ItemRoleType::PdcRound.into(), 50)],
         ));
         world.run_system_once(drive_ammo_readouts).unwrap();
         assert_eq!(
@@ -1748,7 +1748,7 @@ mod tests {
             .rounds = 1;
         world
             .entity_mut(torpedo)
-            .insert(reload_at(ITEM_TORPEDO.into(), 10.0, 1, 0.5));
+            .insert(reload_at(ItemRoleType::Torpedo.into(), 10.0, 1, 0.5));
         world.run_system_once(sync_ammo_readouts).unwrap();
         world.run_system_once(drive_ammo_readouts).unwrap();
 
@@ -1827,7 +1827,7 @@ mod tests {
             .rounds = 1;
         world
             .entity_mut(torpedo)
-            .insert(reload_at(ITEM_TORPEDO.into(), 4.0, 1, 0.5));
+            .insert(reload_at(ItemRoleType::Torpedo.into(), 4.0, 1, 0.5));
         world.run_system_once(sync_ammo_readouts).unwrap();
         world.run_system_once(drive_ammo_readouts).unwrap();
         let reloading = lit_pip_color(&mut world, torpedo).expect("a lit pip");
@@ -1854,7 +1854,7 @@ mod tests {
         let turret = spawn_turret(&mut world, player, Some(SectionAmmo::new(8)));
         world
             .entity_mut(turret)
-            .insert(reload_at(ITEM_PDC_ROUND.into(), 2.0, 8, 0.0));
+            .insert(reload_at(ItemRoleType::PdcRound.into(), 2.0, 8, 0.0));
         world.run_system_once(sync_ammo_readouts).unwrap();
         world.run_system_once(drive_ammo_readouts).unwrap();
 
@@ -1916,7 +1916,7 @@ mod tests {
             .rounds = 0;
         world
             .entity_mut(lance)
-            .insert(reload_at(ITEM_RAIL_SLUG.into(), 12.0, 1, 0.25));
+            .insert(reload_at(ItemRoleType::RailSlug.into(), 12.0, 1, 0.25));
         world.run_system_once(sync_ammo_readouts).unwrap();
         world.run_system_once(drive_ammo_readouts).unwrap();
 
@@ -1928,7 +1928,7 @@ mod tests {
 
         world
             .entity_mut(lance)
-            .insert(reload_at(ITEM_RAIL_SLUG.into(), 12.0, 1, 0.75));
+            .insert(reload_at(ItemRoleType::RailSlug.into(), 12.0, 1, 0.75));
         world.run_system_once(drive_ammo_readouts).unwrap();
         assert_eq!(
             fill_percent(&mut world, lance),
@@ -1984,7 +1984,7 @@ mod tests {
             .rounds = 0;
         world
             .entity_mut(turret)
-            .insert(reload_at(ITEM_PDC_ROUND.into(), 3.0, 200, 0.5));
+            .insert(reload_at(ItemRoleType::PdcRound.into(), 3.0, 200, 0.5));
         world.run_system_once(sync_ammo_readouts).unwrap();
         world.run_system_once(drive_ammo_readouts).unwrap();
 
diff --git a/crates/nova_hud/src/pickup_sight.rs b/crates/nova_hud/src/pickup_sight.rs
index c120c1b15..cfb344dcc 100644
--- a/crates/nova_hud/src/pickup_sight.rs
+++ b/crates/nova_hud/src/pickup_sight.rs
@@ -391,7 +391,7 @@ mod tests {
         app.world_mut()
             .spawn((
                 Name::new("canister"),
-                CargoCanister::new(&test_items(), &ITEM_HULL_PLATE.into(), 1),
+                CargoCanister::new(&test_items(), &ItemRoleType::HullPlate.into(), 1),
                 health,
                 Transform::from_translation(at),
                 GlobalTransform::from_translation(at),
@@ -858,7 +858,7 @@ mod tests {
         let canister = app
             .world_mut()
             .spawn(cargo_canister(
-                CargoCanister::new(&test_items(), &ITEM_HULL_PLATE.into(), 4),
+                CargoCanister::new(&test_items(), &ItemRoleType::HullPlate.into(), 4),
                 Transform::from_xyz(0.0, 0.0, -2.85),
                 Vec3::ZERO,
                 AssetRef::from("canister.glb#Scene0"),
@@ -895,7 +895,7 @@ mod tests {
             app.world()
                 .get::(player)
                 .unwrap()
-                .count(&ITEM_HULL_PLATE.into()),
+                .count(&ItemRoleType::HullPlate.into()),
             4
         );
         assert_eq!(takes.load(std::sync::atomic::Ordering::Relaxed), 1);
diff --git a/crates/nova_hud/src/situation.rs b/crates/nova_hud/src/situation.rs
index 3ea408df3..113799bc3 100644
--- a/crates/nova_hud/src/situation.rs
+++ b/crates/nova_hud/src/situation.rs
@@ -268,7 +268,7 @@ mod tests {
             .world_mut()
             .spawn((
                 PlayerSpaceshipMarker,
-                ShipInventory::new(&items, 10_000, [(ITEM_PDC_ROUND.into(), 4)]),
+                ShipInventory::new(&items, 10_000, [(ItemRoleType::PdcRound.into(), 4)]),
             ))
             .id();
         let section = app
@@ -281,7 +281,7 @@ mod tests {
                         delay: 3.0,
                         amount: 4,
                     },
-                    ITEM_PDC_ROUND.into(),
+                    ItemRoleType::PdcRound.into(),
                 ),
                 ChildOf(ship),
             ))
diff --git a/crates/nova_interface/src/inventory/tests.rs b/crates/nova_interface/src/inventory/tests.rs
index fac5ff3aa..11401668b 100644
--- a/crates/nova_interface/src/inventory/tests.rs
+++ b/crates/nova_interface/src/inventory/tests.rs
@@ -47,7 +47,7 @@ fn hold(plates: u32) -> ShipInventory {
     ShipInventory::new(
         &test_items(),
         400_000,
-        [(ItemDesignId::from(ITEM_HULL_PLATE), plates)],
+        [(ItemDesignId::from(ItemRoleType::HullPlate), plates)],
     )
 }
 
@@ -333,7 +333,7 @@ fn a_dock_ending_mid_trade_hides_the_partner_column_and_closes_its_form() {
     let partner_row = centre_of::(rig.app.world_mut(), |row| {
         *row == InventoryRow {
             side: InventorySideType::Partner,
-            item: ItemDesignId::from(ITEM_HULL_PLATE),
+            item: ItemDesignId::from(ItemRoleType::HullPlate),
         }
     });
     click_at(&mut rig, partner_row);
@@ -400,7 +400,11 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() {
         .world_mut()
         .get_mut::(partner)
         .expect("the partner has a hold")
-        .add(&test_items(), &ItemDesignId::from(ITEM_PDC_ROUND), 7);
+        .add(
+            &test_items(),
+            &ItemDesignId::from(ItemRoleType::PdcRound),
+            7,
+        );
     settle(&mut rig.app);
     take_churn(&mut rig.app);
     let rows = row_entities(rig.app.world_mut());
@@ -409,7 +413,7 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() {
     let partner_row = centre_of::(rig.app.world_mut(), |row| {
         *row == InventoryRow {
             side: InventorySideType::Partner,
-            item: ItemDesignId::from(ITEM_HULL_PLATE),
+            item: ItemDesignId::from(ItemRoleType::HullPlate),
         }
     });
     click_at(&mut rig, partner_row);
@@ -417,7 +421,7 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() {
         rig.app.world().resource::().selected,
         Some((
             InventorySideType::Partner,
-            ItemDesignId::from(ITEM_HULL_PLATE)
+            ItemDesignId::from(ItemRoleType::HullPlate)
         ))
     );
     assert_eq!(take_cues(&mut rig.app), [UiSfx::MenuSelect]);
@@ -491,7 +495,7 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() {
         runtime.selected,
         Some((
             InventorySideType::Partner,
-            ItemDesignId::from(ITEM_HULL_PLATE)
+            ItemDesignId::from(ItemRoleType::HullPlate)
         ))
     );
     assert_eq!(
@@ -520,7 +524,10 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() {
     press_key(&mut rig.app, KeyCode::BracketLeft);
     assert_eq!(
         rig.app.world().resource::().selected,
-        Some((InventorySideType::Own, ItemDesignId::from(ITEM_HULL_PLATE)))
+        Some((
+            InventorySideType::Own,
+            ItemDesignId::from(ItemRoleType::HullPlate)
+        ))
     );
     assert!(!full_shown(rig.app.world_mut()));
     take_cues(&mut rig.app);
@@ -545,7 +552,7 @@ fn clicking_a_row_inspects_it_and_a_filter_chip_hides_other_categories() {
     let rounds_row = centre_of::(rig.app.world_mut(), |row| {
         *row == InventoryRow {
             side: InventorySideType::Partner,
-            item: ItemDesignId::from(ITEM_PDC_ROUND),
+            item: ItemDesignId::from(ItemRoleType::PdcRound),
         }
     });
     click_at(&mut rig, rounds_row);
@@ -608,7 +615,7 @@ fn plates(world: &World, ship: Entity) -> u32 {
     world
         .get::(ship)
         .expect("a ship root carries a ShipInventory")
-        .count(&ItemDesignId::from(ITEM_HULL_PLATE))
+        .count(&ItemDesignId::from(ItemRoleType::HullPlate))
 }
 
 /// The note line's text, if a result is showing.
@@ -639,12 +646,12 @@ fn confirm_moves_items_between_docked_ships_and_a_refusal_changes_nothing() {
     let confirm = |app: &mut App, action, quantity: Option| {
         let command = InventoryActionCommand {
             action,
-            item: ItemDesignId::from(ITEM_HULL_PLATE),
+            item: ItemDesignId::from(ItemRoleType::HullPlate),
             quantity,
         };
         app.world_mut().resource_mut::().draft = Some(InventoryDraft {
             action,
-            item: ItemDesignId::from(ITEM_HULL_PLATE),
+            item: ItemDesignId::from(ItemRoleType::HullPlate),
             quantity,
         });
         app.world_mut().write_message(command);
@@ -776,12 +783,12 @@ fn confirm_trades_items_for_credits_and_a_refusal_changes_nothing() {
     let confirm = |app: &mut App, action, quantity: Option| {
         app.world_mut().resource_mut::().draft = Some(InventoryDraft {
             action,
-            item: ItemDesignId::from(ITEM_HULL_PLATE),
+            item: ItemDesignId::from(ItemRoleType::HullPlate),
             quantity,
         });
         app.world_mut().write_message(InventoryActionCommand {
             action,
-            item: ItemDesignId::from(ITEM_HULL_PLATE),
+            item: ItemDesignId::from(ItemRoleType::HullPlate),
             quantity,
         });
         app.update();
@@ -828,7 +835,7 @@ fn confirm_trades_items_for_credits_and_a_refusal_changes_nothing() {
         .insert(ShipInventory::new(
             &test_items(),
             100_000,
-            [(ItemDesignId::from(ITEM_HULL_PLATE), 9)],
+            [(ItemDesignId::from(ItemRoleType::HullPlate), 9)],
         ));
     assert_eq!(
         confirm(&mut app, Sell, Some(2)),
@@ -1085,7 +1092,7 @@ fn confirm_jettisons_through_the_intake_and_a_refusal_changes_nothing() {
     let player = spawn_player(app.world_mut(), 12);
     let command = |quantity: Option| InventoryActionCommand {
         action: InventoryActionType::Jettison,
-        item: ItemDesignId::from(ITEM_HULL_PLATE),
+        item: ItemDesignId::from(ItemRoleType::HullPlate),
         quantity,
     };
     // Write confirmed commands with their draft open, and run them.
@@ -1093,7 +1100,7 @@ fn confirm_jettisons_through_the_intake_and_a_refusal_changes_nothing() {
         for &quantity in quantities {
             app.world_mut().resource_mut::().draft = Some(InventoryDraft {
                 action: InventoryActionType::Jettison,
-                item: ItemDesignId::from(ITEM_HULL_PLATE),
+                item: ItemDesignId::from(ItemRoleType::HullPlate),
                 quantity,
             });
             app.world_mut().write_message(command(quantity));
@@ -1166,7 +1173,11 @@ fn confirm_jettisons_through_the_intake_and_a_refusal_changes_nothing() {
     app.world_mut()
         .entity_mut(intake)
         .insert(CargoIntakeEjectionQueue(VecDeque::from([
-            CargoCanister::new(&test_items(), &ItemDesignId::from(ITEM_HULL_PLATE), 19),
+            CargoCanister::new(
+                &test_items(),
+                &ItemDesignId::from(ItemRoleType::HullPlate),
+                19,
+            ),
         ])));
     assert_eq!(
         confirm(&mut app, &[Some(40)]),
@@ -1244,7 +1255,7 @@ fn the_inventory_pane_waits_for_the_merged_item_catalog() {
 
     rig.app.insert_resource(catalog);
     let jettison = InventoryActionType::Jettison;
-    let item = ItemDesignId::from(ITEM_HULL_PLATE);
+    let item = ItemDesignId::from(ItemRoleType::HullPlate);
     rig.app.world_mut().resource_mut::().draft = Some(InventoryDraft {
         action: jettison,
         item: item.clone(),
@@ -1387,7 +1398,7 @@ fn a_selected_row_opens_a_one_unit_draft_that_every_quantity_control_sets() {
         rig.app.world().resource::().draft,
         Some(InventoryDraft {
             action: InventoryActionType::Take,
-            item: ItemDesignId::from(ITEM_HULL_PLATE),
+            item: ItemDesignId::from(ItemRoleType::HullPlate),
             quantity: Some(1),
         })
     );
@@ -1820,7 +1831,7 @@ mod generated_wreck {
                     allegiance: None,
                     capabilities: default(),
                     inventory: ShipInventoryStock::new([(
-                        ItemDesignId::from(ITEM_HULL_PLATE),
+                        ItemDesignId::from(ItemRoleType::HullPlate),
                         PLAYER_PLATES,
                     )]),
                     lootable: false,
diff --git a/crates/nova_interface/src/ship/scene.rs b/crates/nova_interface/src/ship/scene.rs
index 5a41430e6..bea01a94f 100644
--- a/crates/nova_interface/src/ship/scene.rs
+++ b/crates/nova_interface/src/ship/scene.rs
@@ -561,7 +561,7 @@ pub(crate) fn ship_input(
         let stock = q_inventory
             .single()
             .ok()
-            .map(|stock| stock.count(&ItemDesignId::from(ITEM_HULL_PLATE)));
+            .map(|stock| stock.count(&ItemDesignId::from(ItemRoleType::HullPlate)));
         runtime.requested_plates = view.map(|view| {
             plate_repair_limit(view.health.as_ref(), view.disabled, stock.unwrap_or(0))
         });
@@ -1020,7 +1020,7 @@ pub(crate) fn on_ship_repair_all_button(
     runtime.requested_plates = Some(plate_repair_limit(
         view.health.as_ref(),
         view.disabled,
-        stock.count(&ItemDesignId::from(ITEM_HULL_PLATE)),
+        stock.count(&ItemDesignId::from(ItemRoleType::HullPlate)),
     ));
 }
 
@@ -1045,7 +1045,7 @@ pub(crate) fn on_ship_repair_slider(
     let max = plate_repair_limit(
         view.health.as_ref(),
         view.disabled,
-        stock.count(&ItemDesignId::from(ITEM_HULL_PLATE)),
+        stock.count(&ItemDesignId::from(ItemRoleType::HullPlate)),
     );
     if max > 0 {
         runtime.requested_plates = Some(change.value.round().clamp(1.0, max as f32) as u32);
@@ -1150,7 +1150,7 @@ pub(crate) fn update_ship_panel(
     // caught up to yet.
     let inventory = q_inventory.single().ok();
     let stock = inventory.map_or(0, |inventory| {
-        inventory.count(&ItemDesignId::from(ITEM_HULL_PLATE))
+        inventory.count(&ItemDesignId::from(ItemRoleType::HullPlate))
     });
     let selected = inventory.and(selected);
     // This system's own rewrite below does not read as a change on its next
diff --git a/crates/nova_interface/src/ship/sections.rs b/crates/nova_interface/src/ship/sections.rs
index 23838bb67..2ed6c987f 100644
--- a/crates/nova_interface/src/ship/sections.rs
+++ b/crates/nova_interface/src/ship/sections.rs
@@ -491,10 +491,10 @@ pub(crate) fn repair_section(
         Some(health),
         disabled,
         requested_plates,
-        inventory.count(&ItemDesignId::from(ITEM_HULL_PLATE)),
+        inventory.count(&ItemDesignId::from(ItemRoleType::HullPlate)),
     ) {
         Ok(repair) => {
-            inventory.remove(&ItemDesignId::from(ITEM_HULL_PLATE), repair.plates);
+            inventory.remove(&ItemDesignId::from(ItemRoleType::HullPlate), repair.plates);
             health.current = repair.current;
             let noun = if repair.plates == 1 {
                 "plate"
diff --git a/crates/nova_interface/src/ship/tests.rs b/crates/nova_interface/src/ship/tests.rs
index 4bb57384d..6e5fb9e2d 100644
--- a/crates/nova_interface/src/ship/tests.rs
+++ b/crates/nova_interface/src/ship/tests.rs
@@ -149,7 +149,7 @@ fn ship_repair_spends_plates_only_on_a_damaged_player_section() {
         app.world()
             .get::(ship)
             .unwrap()
-            .count(&ItemDesignId::from(ITEM_HULL_PLATE))
+            .count(&ItemDesignId::from(ItemRoleType::HullPlate))
     };
     let note = |app: &App| {
         app.world()
@@ -162,7 +162,7 @@ fn ship_repair_spends_plates_only_on_a_damaged_player_section() {
         ShipInventory::new(
             &items,
             400_000,
-            [(ItemDesignId::from(ITEM_HULL_PLATE), count)],
+            [(ItemDesignId::from(ItemRoleType::HullPlate), count)],
         )
     };
 
@@ -275,7 +275,7 @@ fn ship_repair_spends_selected_quantity_and_refuses_stale_requests_atomically()
         ShipInventory::new(
             &items,
             400_000,
-            [(ItemDesignId::from(ITEM_HULL_PLATE), count)],
+            [(ItemDesignId::from(ItemRoleType::HullPlate), count)],
         )
     };
     let health = |app: &App| app.world().get::(turret).unwrap().current;
@@ -283,7 +283,7 @@ fn ship_repair_spends_selected_quantity_and_refuses_stale_requests_atomically()
         app.world()
             .get::(ship)
             .unwrap()
-            .count(&ItemDesignId::from(ITEM_HULL_PLATE))
+            .count(&ItemDesignId::from(ItemRoleType::HullPlate))
     };
     let repair = |app: &mut App, requested_plates| {
         app.world_mut().write_message(SectionRepairCommand {
@@ -817,7 +817,7 @@ fn ship_input_selection_repair_reset_survives_a_missing_player_inventory() {
     app.world_mut().entity_mut(ship).insert(ShipInventory::new(
         &items,
         400_000,
-        [(ItemDesignId::from(ITEM_HULL_PLATE), 10)],
+        [(ItemDesignId::from(ItemRoleType::HullPlate), 10)],
     ));
     app.world_mut().run_system_once(ship_input).unwrap();
     let runtime = app.world().resource::();
@@ -1235,7 +1235,7 @@ fn panel_buttons_raise_section_repair_command() {
     app.world_mut().entity_mut(ship).insert(ShipInventory::new(
         &items,
         400_000,
-        [(ItemDesignId::from(ITEM_HULL_PLATE), 1)],
+        [(ItemDesignId::from(ItemRoleType::HullPlate), 1)],
     ));
     let repair = app
         .world_mut()
@@ -1325,7 +1325,7 @@ fn update_ship_panel_reflects_selection() {
     app.world_mut().entity_mut(ship).insert(ShipInventory::new(
         &items,
         400_000,
-        [(ItemDesignId::from(ITEM_HULL_PLATE), 1)],
+        [(ItemDesignId::from(ItemRoleType::HullPlate), 1)],
     ));
     app.world_mut()
         .entity_mut(turret)
@@ -1469,7 +1469,7 @@ fn update_ship_panel_reflects_selection() {
     app.world_mut().entity_mut(ship).insert(ShipInventory::new(
         &items,
         400_000,
-        [(ItemDesignId::from(ITEM_HULL_PLATE), 10)],
+        [(ItemDesignId::from(ItemRoleType::HullPlate), 10)],
     ));
     app.world_mut().resource_mut::().selected = Some(thruster);
     app.update();
@@ -1501,7 +1501,7 @@ fn update_ship_panel_reflects_selection() {
     app.world_mut().entity_mut(ship).insert(ShipInventory::new(
         &items,
         400_000,
-        [(ItemDesignId::from(ITEM_HULL_PLATE), 10)],
+        [(ItemDesignId::from(ItemRoleType::HullPlate), 10)],
     ));
     app.update();
     assert_eq!(
@@ -1588,7 +1588,7 @@ fn update_ship_panel_preview_names_the_exact_repair_refusal() {
     app.world_mut().entity_mut(ship).insert(ShipInventory::new(
         &items,
         400_000,
-        [(ItemDesignId::from(ITEM_HULL_PLATE), 1)],
+        [(ItemDesignId::from(ItemRoleType::HullPlate), 1)],
     ));
     app.world_mut()
         .run_system_once(assign_section_codes)
@@ -1660,7 +1660,7 @@ fn update_ship_panel_hides_the_repair_form_after_a_full_repair() {
     app.world_mut().entity_mut(ship).insert(ShipInventory::new(
         &items,
         400_000,
-        [(ItemDesignId::from(ITEM_HULL_PLATE), 1)],
+        [(ItemDesignId::from(ItemRoleType::HullPlate), 1)],
     ));
     app.world_mut()
         .run_system_once(assign_section_codes)
diff --git a/crates/nova_menu/src/tests/load_screen.rs b/crates/nova_menu/src/tests/load_screen.rs
index f3dd3192a..8d0e4fbca 100644
--- a/crates/nova_menu/src/tests/load_screen.rs
+++ b/crates/nova_menu/src/tests/load_screen.rs
@@ -199,7 +199,7 @@ fn load_greys_a_world_while_its_check_runs_and_refuses_one_holding_an_unknown_it
     let player = arm_save_fixture(app.world_mut());
     // The fixture player holds no stock; give it one item so the loaded
     // catalog can go on to lack it.
-    let hull_plate = ItemDesignId::from(ITEM_HULL_PLATE);
+    let hull_plate = ItemDesignId::from(ItemRoleType::HullPlate);
     let items = app.world().resource::().clone();
     let mut inventory = ShipInventory::new(&items, 10_000, std::iter::empty());
     inventory.add(&items, &hull_plate, 1);
diff --git a/crates/nova_probe/src/capabilities/snapshot.rs b/crates/nova_probe/src/capabilities/snapshot.rs
index 198bfb982..9af91357c 100644
--- a/crates/nova_probe/src/capabilities/snapshot.rs
+++ b/crates/nova_probe/src/capabilities/snapshot.rs
@@ -1636,7 +1636,7 @@ mod tests {
     use std::sync::atomic::{AtomicU32, Ordering};
 
     use nova_gameplay::{
-        prelude::{DamageType, ITEM_HULL_PLATE, ITEM_IRON_ORE, ITEM_PDC_ROUND},
+        prelude::{DamageType, ItemRoleType},
         test_support::test_items,
     };
     use nova_ship::prelude::{unit_cube_link_points, SectionReloadConfig};
@@ -1687,7 +1687,7 @@ mod tests {
                 delay: 4.0,
                 amount: 5,
             },
-            ITEM_PDC_ROUND.into(),
+            ItemRoleType::PdcRound.into(),
         );
         reload.elapsed = 1.5;
         let section = app
@@ -1867,8 +1867,8 @@ mod tests {
         fn spawn_canisters(app: &mut App, ids: [u64; 2]) {
             let items = test_items();
             for id in ids {
-                let mut canister = CargoCanister::new(&items, &ITEM_IRON_ORE.into(), 2);
-                canister.add(&items, &ITEM_HULL_PLATE.into(), 1);
+                let mut canister = CargoCanister::new(&items, &ItemRoleType::IronOre.into(), 2);
+                canister.add(&items, &ItemRoleType::HullPlate.into(), 1);
                 app.world_mut().spawn((
                     canister,
                     CargoCanisterRuntimeId(id),
@@ -1918,8 +1918,11 @@ mod tests {
     #[should_panic(expected = "has no runtime id")]
     fn a_live_canister_without_runtime_id_fails_snapshot_capture() {
         let mut app = rig();
-        app.world_mut()
-            .spawn(CargoCanister::new(&test_items(), &ITEM_IRON_ORE.into(), 1));
+        app.world_mut().spawn(CargoCanister::new(
+            &test_items(),
+            &ItemRoleType::IronOre.into(),
+            1,
+        ));
         capture_snapshot(app.world_mut(), "test");
     }
 
@@ -1929,7 +1932,7 @@ mod tests {
         let mut app = rig();
         for _ in 0..2 {
             app.world_mut().spawn((
-                CargoCanister::new(&test_items(), &ITEM_IRON_ORE.into(), 1),
+                CargoCanister::new(&test_items(), &ItemRoleType::IronOre.into(), 1),
                 CargoCanisterRuntimeId(7),
                 Transform::IDENTITY,
                 LinearVelocity(Vec3::ZERO),
@@ -1994,7 +1997,10 @@ mod tests {
             ShipInventory::new(
                 &items,
                 100_000,
-                [(ITEM_IRON_ORE.into(), 3), (ITEM_HULL_PLATE.into(), 2)],
+                [
+                    (ItemRoleType::IronOre.into(), 3),
+                    (ItemRoleType::HullPlate.into(), 2),
+                ],
             ),
             ShipCredits(250),
         ));
@@ -2016,8 +2022,8 @@ mod tests {
 
         let mut entity = app.world_mut().entity_mut(player);
         let mut inventory = entity.get_mut::().unwrap();
-        inventory.remove(&ITEM_HULL_PLATE.into(), 2);
-        inventory.add(&items, &ITEM_PDC_ROUND.into(), 5);
+        inventory.remove(&ItemRoleType::HullPlate.into(), 2);
+        inventory.add(&items, &ItemRoleType::PdcRound.into(), 5);
         entity.get_mut::().unwrap().0 = 330;
         app.update();
 
diff --git a/crates/nova_scenario/src/actions/spawn.rs b/crates/nova_scenario/src/actions/spawn.rs
index 9c928005e..ceaeec43d 100644
--- a/crates/nova_scenario/src/actions/spawn.rs
+++ b/crates/nova_scenario/src/actions/spawn.rs
@@ -972,7 +972,7 @@ mod tests {
         let items = world.resource::().clone();
         world.get_mut::(ship).unwrap().add(
             &items,
-            &ItemDesignId::from(ITEM_IRON_ORE),
+            &ItemDesignId::from(ItemRoleType::IronOre),
             3,
         );
         world.entity_mut(ship).insert(ShipCredits(555));
diff --git a/crates/nova_scenario/src/lint/scenario.rs b/crates/nova_scenario/src/lint/scenario.rs
index fee1fa4ce..b2c2b54fc 100644
--- a/crates/nova_scenario/src/lint/scenario.rs
+++ b/crates/nova_scenario/src/lint/scenario.rs
@@ -2424,10 +2424,10 @@ mod tests {
     #[test]
     fn inventory_stock_heavier_than_the_hold_fails_lint() {
         use nova_gameplay::{
-            prelude::{ItemDesignId, ITEM_HULL_PLATE, ITEM_PDC_ROUND},
+            prelude::{ItemDesignId, ItemRoleType},
             test_support::test_items,
         };
-        let overstock = |item: &str, count| {
+        let overstock = |item: ItemRoleType, count| {
             let mut spawn = spawn_armed_ship(
                 "warship",
                 SpaceshipController::AI(AIControllerConfig::default()),
@@ -2459,16 +2459,16 @@ mod tests {
         };
 
         assert!(
-            overstock(ITEM_HULL_PLATE, 10).is_empty(),
+            overstock(ItemRoleType::HullPlate, 10).is_empty(),
             "100 kg fits a 100 kg hold"
         );
         assert_eq!(
-            overstock(ITEM_HULL_PLATE, 11),
+            overstock(ItemRoleType::HullPlate, 11),
             ["ship 'warship': inventory stock of 110 kg exceeds its 100 kg hold"]
         );
-        assert!(overstock(ITEM_PDC_ROUND, 500).is_empty());
+        assert!(overstock(ItemRoleType::PdcRound, 500).is_empty());
         assert_eq!(
-            overstock(ITEM_PDC_ROUND, 501),
+            overstock(ItemRoleType::PdcRound, 501),
             ["ship 'warship': inventory stock of 100.2 kg exceeds its 100 kg hold"]
         );
     }
@@ -2478,7 +2478,7 @@ mod tests {
     #[test]
     fn a_ship_stocking_an_item_the_catalog_lacks_fails_lint() {
         use nova_gameplay::{
-            prelude::{ItemDesignId, ShipInventoryStock, ITEM_HULL_PLATE},
+            prelude::{ItemDesignId, ItemRoleType, ShipInventoryStock},
             test_support::test_items,
         };
         let mut spawn = spawn_armed_ship(
@@ -2493,7 +2493,7 @@ mod tests {
             unreachable!("spawn_armed_ship spawns a spaceship");
         };
         ship.inventory = ShipInventoryStock::new([
-            (ItemDesignId::from(ITEM_HULL_PLATE), 1),
+            (ItemDesignId::from(ItemRoleType::HullPlate), 1),
             (ItemDesignId::from("lost_core"), 50),
         ]);
         let s = scenario(vec![spawn], vec![]);
diff --git a/crates/nova_scenario/src/mining.rs b/crates/nova_scenario/src/mining.rs
index b259d26d6..12f72dede 100644
--- a/crates/nova_scenario/src/mining.rs
+++ b/crates/nova_scenario/src/mining.rs
@@ -148,15 +148,14 @@ const SPARK_UP_PROPERTY: &str = "up";
 
 /// The ore a pulse into an asteroid of `kind` yields, or `None` for a kind
 /// that holds none, such as `plain`, the unshaded control rock.
-pub fn ore_for_asteroid_kind(kind: &AsteroidKindId) -> Option {
-    let ore = match kind.as_str() {
-        KIND_ROCK => ITEM_STONE_ORE,
-        KIND_METAL => ITEM_IRON_ORE,
-        KIND_ICE => ITEM_WATER_ICE,
-        KIND_CARBON => ITEM_CARBON_ORE,
-        _ => return None,
-    };
-    Some(ore.into())
+pub fn ore_for_asteroid_kind(kind: &AsteroidKindId) -> Option {
+    match kind.as_str() {
+        KIND_ROCK => Some(ItemRoleType::StoneOre),
+        KIND_METAL => Some(ItemRoleType::IronOre),
+        KIND_ICE => Some(ItemRoleType::WaterIce),
+        KIND_CARBON => Some(ItemRoleType::CarbonOre),
+        _ => None,
+    }
 }
 
 /// Why a pulse takes nothing, in check order.
@@ -393,7 +392,7 @@ struct BeamAim {
     /// The rock's field node.
     node: Entity,
     /// The ore the rock yields.
-    item: ItemDesignId,
+    item: ItemRoleType,
     /// Where the ray meets the rock's collider, in world space.
     at: Vec3,
     /// The collider's outward normal there.
@@ -433,6 +432,7 @@ fn pulse_mining_beams(
     q_locks: Query<&TravelLock>,
     q_rocks: RockQuery,
     mut q_nodes: RockNodeQuery,
+    items: Res,
 ) {
     for section in &q_inactive {
         commands
@@ -496,6 +496,7 @@ fn pulse_mining_beams(
                 &aim,
                 direction,
                 config.carve_radius_cells,
+                &items,
                 &mut commands,
                 &mut q_nodes,
                 &mut owed,
@@ -563,6 +564,7 @@ fn carve(
     aim: &BeamAim,
     direction: Vec3,
     radius_cells: f32,
+    items: &GameItems,
     commands: &mut Commands,
     q_nodes: &mut RockNodeQuery,
     owed: &mut HashMap,
@@ -603,7 +605,7 @@ fn carve(
         return 0;
     }
     let pulse = MinedOre {
-        item: aim.item.clone(),
+        item: items.role(aim.item).id.clone(),
         corners,
         at,
         normal: local_normal,
@@ -1348,7 +1350,7 @@ mod tests {
             let paid = app.world().resource::().paid();
             let world = app.world_mut();
             assert_eq!(
-                ore_in_world(world, &ItemDesignId::from(ITEM_STONE_ORE)),
+                ore_in_world(world, &ItemDesignId::from(ItemRoleType::StoneOre)),
                 paid
             );
             let drifting_now = canisters(world);
@@ -1385,7 +1387,10 @@ mod tests {
             );
         }
         let world = app.world_mut();
-        assert_eq!(drifting(world, &ItemDesignId::from(ITEM_STONE_ORE)), paid);
+        assert_eq!(
+            drifting(world, &ItemDesignId::from(ItemRoleType::StoneOre)),
+            paid
+        );
         let mut minted = Vec::new();
         for canister in canisters(world) {
             let id = world
@@ -1401,7 +1406,7 @@ mod tests {
                 .collect();
             assert!(
                 held.iter()
-                    .all(|(item, _)| *item == &ItemDesignId::from(ITEM_STONE_ORE)),
+                    .all(|(item, _)| *item == &ItemDesignId::from(ItemRoleType::StoneOre)),
                 "{held:?}"
             );
             assert!(world.get::(canister).is_some());
@@ -1552,10 +1557,10 @@ mod tests {
         app.update();
         let world = app.world_mut();
         for item in [
-            ITEM_STONE_ORE,
-            ITEM_IRON_ORE,
-            ITEM_WATER_ICE,
-            ITEM_CARBON_ORE,
+            ItemRoleType::StoneOre,
+            ItemRoleType::IronOre,
+            ItemRoleType::WaterIce,
+            ItemRoleType::CarbonOre,
         ] {
             let item = ItemDesignId::from(item);
             assert_eq!(ore_in_world(world, &item), 0, "{item:?}");
@@ -1643,7 +1648,7 @@ mod tests {
     /// time, and despawns with the last: all 45 ore, none lost.
     #[test]
     fn an_exhausted_rock_keeps_blocked_canisters_until_the_birth_point_clears() {
-        let iron = ItemDesignId::from(ITEM_IRON_ORE);
+        let iron = ItemDesignId::from(ItemRoleType::IronOre);
         let mut app = mining_app();
         let sector = app
             .world_mut()
@@ -1742,7 +1747,7 @@ mod tests {
     /// corners again, and the drop drains its queue as it would have.
     #[test]
     fn owed_ore_and_a_waiting_drop_come_back_from_a_frozen_sector() {
-        let iron = ItemDesignId::from(ITEM_IRON_ORE);
+        let iron = ItemDesignId::from(ItemRoleType::IronOre);
         let mut app = mining_app();
         let (rock, node) = spawn_rock(&mut app, KIND_ROCK);
         let owed = MinedOre {
diff --git a/crates/nova_scenario/src/objects/beacon.rs b/crates/nova_scenario/src/objects/beacon.rs
index fd917c5a9..5481e2da0 100644
--- a/crates/nova_scenario/src/objects/beacon.rs
+++ b/crates/nova_scenario/src/objects/beacon.rs
@@ -381,7 +381,7 @@ mod tests {
             Transform::IDENTITY,
         ));
         let items = test_items();
-        let hull_plate = ItemDesignId::from(ITEM_HULL_PLATE);
+        let hull_plate = ItemDesignId::from(ItemRoleType::HullPlate);
         let inventory = ShipInventory::new(
             &items,
             items.design(&hull_plate).mass_g * 10,
diff --git a/crates/nova_scenario/src/objects/spaceship.rs b/crates/nova_scenario/src/objects/spaceship.rs
index 7c05e1a21..fa9ee4c6f 100644
--- a/crates/nova_scenario/src/objects/spaceship.rs
+++ b/crates/nova_scenario/src/objects/spaceship.rs
@@ -2129,7 +2129,7 @@ mod tests {
                         ..default()
                     }),
                     inventory: ShipInventoryStock::new([(
-                        ItemDesignId::from(ITEM_HULL_PLATE),
+                        ItemDesignId::from(ItemRoleType::HullPlate),
                         plates,
                     )]),
                     ..default()
@@ -2149,7 +2149,10 @@ mod tests {
         let inventory = world.entity(ship).get::().unwrap();
         assert_eq!(inventory.capacity_g(), 200_000);
         assert_eq!(inventory.used_g(&test_items()), 120_000);
-        assert_eq!(inventory.count(&ItemDesignId::from(ITEM_HULL_PLATE)), 12);
+        assert_eq!(
+            inventory.count(&ItemDesignId::from(ItemRoleType::HullPlate)),
+            12
+        );
         assert!(world.entity(ship).get::().is_none());
     }
 
diff --git a/crates/nova_ship/src/input/targeting/radar.rs b/crates/nova_ship/src/input/targeting/radar.rs
index 54fcf3640..cb262441b 100644
--- a/crates/nova_ship/src/input/targeting/radar.rs
+++ b/crates/nova_ship/src/input/targeting/radar.rs
@@ -548,7 +548,7 @@ mod tests {
         assert!(at.translation.length() > TargetingSettings::default().unsigned_lock_range);
         let canister = world
             .spawn(cargo_canister(
-                CargoCanister::new(&test_items(), &ITEM_HULL_PLATE.into(), 1),
+                CargoCanister::new(&test_items(), &ItemRoleType::HullPlate.into(), 1),
                 at,
                 Vec3::ZERO,
                 AssetRef::from("canister.glb#Scene0"),
diff --git a/crates/nova_ship/src/sections/ammo.rs b/crates/nova_ship/src/sections/ammo.rs
index f080d20e2..d3ae6267e 100644
--- a/crates/nova_ship/src/sections/ammo.rs
+++ b/crates/nova_ship/src/sections/ammo.rs
@@ -368,10 +368,7 @@ pub struct SectionReloadComplete {
 
 #[cfg(test)]
 mod tests {
-    use nova_gameplay::{
-        prelude::{ITEM_PDC_ROUND, ITEM_TORPEDO},
-        test_support::test_items,
-    };
+    use nova_gameplay::{prelude::ItemRoleType, test_support::test_items};
 
     use super::*;
 
@@ -405,7 +402,10 @@ mod tests {
     }
 
     fn reload_cfg(delay: f32, amount: u32) -> SectionReload {
-        SectionReload::from_config(SectionReloadConfig { delay, amount }, ITEM_PDC_ROUND.into())
+        SectionReload::from_config(
+            SectionReloadConfig { delay, amount },
+            ItemRoleType::PdcRound.into(),
+        )
     }
 
     /// A hold of `rounds` PDC rounds with room to spare.
@@ -414,7 +414,7 @@ mod tests {
         if rounds == 0 {
             return ShipInventory::new(&items, 1_000_000, []);
         }
-        ShipInventory::new(&items, 1_000_000, [(ITEM_PDC_ROUND.into(), rounds)])
+        ShipInventory::new(&items, 1_000_000, [(ItemRoleType::PdcRound.into(), rounds)])
     }
 
     #[test]
@@ -429,12 +429,12 @@ mod tests {
         assert!((reload.progress() - 2.0 / 3.0).abs() < 1e-6);
         reload.advance(&mut ammo, &mut inventory, 1.0);
         assert_eq!(ammo.rounds, 200);
-        assert_eq!(inventory.count(&ITEM_PDC_ROUND.into()), 800);
+        assert_eq!(inventory.count(&ItemRoleType::PdcRound.into()), 800);
         assert_eq!(reload.progress(), 0.0);
         reload.advance(&mut ammo, &mut inventory, 6.0);
         assert_eq!(ammo.rounds, 500, "the final batch clamps to capacity");
         assert_eq!(
-            inventory.count(&ITEM_PDC_ROUND.into()),
+            inventory.count(&ItemRoleType::PdcRound.into()),
             500,
             "the clamped batch takes only the 100 rounds it loads"
         );
@@ -461,7 +461,7 @@ mod tests {
         assert_eq!(reload.progress(), 0.0);
 
         // Stock that arrives later starts a fresh delay, not an owed batch.
-        inventory.add(&test_items(), &ITEM_PDC_ROUND.into(), 10);
+        inventory.add(&test_items(), &ItemRoleType::PdcRound.into(), 10);
         reload.advance(&mut ammo, &mut inventory, 1.0);
         assert_eq!(ammo.rounds, 150);
         reload.advance(&mut ammo, &mut inventory, 2.0);
@@ -478,12 +478,12 @@ mod tests {
                 delay: 1.0,
                 amount: 1,
             },
-            ITEM_TORPEDO.into(),
+            ItemRoleType::Torpedo.into(),
         );
         let mut inventory = reserve(1000);
         reload.advance(&mut ammo, &mut inventory, 5.0);
         assert_eq!(ammo.rounds, 0, "PDC rounds do not load a torpedo bay");
-        assert_eq!(inventory.count(&ITEM_PDC_ROUND.into()), 1000);
+        assert_eq!(inventory.count(&ItemRoleType::PdcRound.into()), 1000);
     }
 
     #[test]
@@ -534,7 +534,7 @@ mod tests {
         assert!(!reload.is_reloading(&ammo, 10));
         reload.advance(&mut ammo, &mut inventory, 100.0);
         assert_eq!(ammo.rounds, 4);
-        assert_eq!(inventory.count(&ITEM_PDC_ROUND.into()), 10);
+        assert_eq!(inventory.count(&ItemRoleType::PdcRound.into()), 10);
         assert_eq!(reload.progress(), 0.0);
     }
 
@@ -623,7 +623,7 @@ mod tests {
                 .world()
                 .get::(ship)
                 .unwrap()
-                .count(&ITEM_PDC_ROUND.into());
+                .count(&ItemRoleType::PdcRound.into());
             (loaded, held)
         };
 
diff --git a/crates/nova_ship/src/sections/cargo_intake_section/tests.rs b/crates/nova_ship/src/sections/cargo_intake_section/tests.rs
index 17e9b8cec..eb07ba01c 100644
--- a/crates/nova_ship/src/sections/cargo_intake_section/tests.rs
+++ b/crates/nova_ship/src/sections/cargo_intake_section/tests.rs
@@ -63,7 +63,7 @@ fn intakes_app(stock: u32, intakes_x: &[f32]) -> (App, Entity, Vec) {
             SpaceshipRootMarker,
             RigidBody::Dynamic,
             Transform::default(),
-            ShipInventory::new(&items, 400_000, [(ITEM_HULL_PLATE.into(), stock)]),
+            ShipInventory::new(&items, 400_000, [(ItemRoleType::HullPlate.into(), stock)]),
         ))
         .id();
     app.world_mut().spawn((
@@ -124,7 +124,7 @@ fn drifting_canister(
     let items = app.world().resource::().clone();
     app.world_mut()
         .spawn(cargo_canister(
-            CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), count),
+            CargoCanister::new(&items, &ItemRoleType::HullPlate.into(), count),
             Transform::from_translation(at).with_rotation(rotation),
             velocity,
             AssetRef::from("canister.glb#Scene0"),
@@ -148,7 +148,7 @@ fn plates(app: &App, ship: Entity) -> u32 {
     app.world()
         .get::(ship)
         .unwrap()
-        .count(&ITEM_HULL_PLATE.into())
+        .count(&ItemRoleType::HullPlate.into())
 }
 
 fn takes(app: &App) -> u32 {
@@ -484,7 +484,7 @@ fn a_canister_larger_than_the_free_room_is_refused_whole() {
     app.world_mut()
         .get_mut::(ship)
         .unwrap()
-        .remove(&ITEM_HULL_PLATE.into(), 2);
+        .remove(&ItemRoleType::HullPlate.into(), 2);
     for _ in 0..4 {
         app.update();
     }
@@ -530,11 +530,11 @@ fn an_ejected_canister_leaves_without_being_taken_back() {
     app.world_mut()
         .entity_mut(intake)
         .insert(CargoIntakeEjectionQueue(VecDeque::from([
-            CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 4),
+            CargoCanister::new(&items, &ItemRoleType::HullPlate.into(), 4),
         ])));
 
     // The ejection waits for the door, then leaves across the face.
-    let hull_plate = ITEM_HULL_PLATE.into();
+    let hull_plate = ItemRoleType::HullPlate.into();
     let mut frames = 0;
     let (canister, birth) = loop {
         app.update();
@@ -573,7 +573,7 @@ fn speculative_ejection_contact_is_refused_until_actual_reentry() {
     app.world_mut()
         .entity_mut(intake)
         .insert(CargoIntakeEjectionQueue(VecDeque::from([
-            CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 4),
+            CargoCanister::new(&items, &ItemRoleType::HullPlate.into(), 4),
         ])));
 
     let mut frames = 0;
@@ -678,9 +678,9 @@ fn queued_canisters_leave_in_order_through_an_open_door_once_each_birth_point_cl
     let (mut app, ship, intake) = intake_app(8);
     let items = app.world().resource::().clone();
     let queued = [
-        CargoCanister::new(&items, &ITEM_TORPEDO.into(), 1),
-        CargoCanister::new(&items, &ITEM_RAIL_SLUG.into(), 10),
-        CargoCanister::new(&items, &ITEM_PDC_ROUND.into(), 1_000),
+        CargoCanister::new(&items, &ItemRoleType::Torpedo.into(), 1),
+        CargoCanister::new(&items, &ItemRoleType::RailSlug.into(), 10),
+        CargoCanister::new(&items, &ItemRoleType::PdcRound.into(), 1_000),
     ];
 
     // The interface pauses virtual time: a queue confirmed then waits.
@@ -782,8 +782,8 @@ fn a_despawned_intake_loses_its_waiting_canisters() {
     app.world_mut()
         .entity_mut(intake)
         .insert(CargoIntakeEjectionQueue(VecDeque::from([
-            CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 4),
-            CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 3),
+            CargoCanister::new(&items, &ItemRoleType::HullPlate.into(), 4),
+            CargoCanister::new(&items, &ItemRoleType::HullPlate.into(), 3),
         ])));
     let mut frames = 0;
     while canisters(&mut app).is_empty() {
@@ -802,7 +802,11 @@ fn a_despawned_intake_loses_its_waiting_canisters() {
         .collect();
     assert_eq!(
         left,
-        [CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 4)]
+        [CargoCanister::new(
+            &items,
+            &ItemRoleType::HullPlate.into(),
+            4
+        )]
     );
     assert!(app
         .world_mut()
diff --git a/crates/nova_ship/src/sections/railgun_section/firing.rs b/crates/nova_ship/src/sections/railgun_section/firing.rs
index a9d067c9a..9be5e40b4 100644
--- a/crates/nova_ship/src/sections/railgun_section/firing.rs
+++ b/crates/nova_ship/src/sections/railgun_section/firing.rs
@@ -29,9 +29,10 @@ pub(super) fn insert_railgun_section(
     };
     commands.entity(entity).insert(SectionAmmo::new(capacity));
     if let Some(reload) = config.reload.batch() {
-        commands
-            .entity(entity)
-            .insert(SectionReload::from_config(reload, ITEM_RAIL_SLUG.into()));
+        commands.entity(entity).insert(SectionReload::from_config(
+            reload,
+            ItemRoleType::RailSlug.into(),
+        ));
     }
 }
 
diff --git a/crates/nova_ship/src/sections/railgun_section/tests.rs b/crates/nova_ship/src/sections/railgun_section/tests.rs
index cbe3975c4..f3a56004e 100644
--- a/crates/nova_ship/src/sections/railgun_section/tests.rs
+++ b/crates/nova_ship/src/sections/railgun_section/tests.rs
@@ -748,7 +748,7 @@ fn a_scripted_railgun_order_waits_for_an_empty_gun_to_reload() {
     app.world_mut().entity_mut(ship).insert(ShipInventory::new(
         &test_items(),
         20_000,
-        [(ITEM_RAIL_SLUG.into(), 1)],
+        [(ItemRoleType::RailSlug.into(), 1)],
     ));
 
     // Empty the magazine before the order lands.
diff --git a/crates/nova_ship/src/sections/torpedo_section/bay.rs b/crates/nova_ship/src/sections/torpedo_section/bay.rs
index 200e817c2..5e339c76b 100644
--- a/crates/nova_ship/src/sections/torpedo_section/bay.rs
+++ b/crates/nova_ship/src/sections/torpedo_section/bay.rs
@@ -166,9 +166,10 @@ pub(super) fn insert_torpedo_section(
         // Every bay type loads the same torpedo: the bay, not the item,
         // decides its flight.
         if let Some(reload) = config.reload.batch() {
-            commands
-                .entity(entity)
-                .insert(SectionReload::from_config(reload, ITEM_TORPEDO.into()));
+            commands.entity(entity).insert(SectionReload::from_config(
+                reload,
+                ItemRoleType::Torpedo.into(),
+            ));
         }
     }
 }
@@ -1377,13 +1378,13 @@ mod tests {
                     delay: 0.2,
                     amount: 1,
                 },
-                ITEM_TORPEDO.into(),
+                ItemRoleType::Torpedo.into(),
             ));
         let ship = app.world().get::(section).unwrap().parent();
         app.world_mut().entity_mut(ship).insert(ShipInventory::new(
             &test_items(),
             10_000_000,
-            [(ITEM_TORPEDO.into(), 10)],
+            [(ItemRoleType::Torpedo.into(), 10)],
         ));
 
         for _ in 0..12 {
diff --git a/crates/nova_ship/src/sections/turret_section/firing.rs b/crates/nova_ship/src/sections/turret_section/firing.rs
index fc9fe4301..4ea76ceb3 100644
--- a/crates/nova_ship/src/sections/turret_section/firing.rs
+++ b/crates/nova_ship/src/sections/turret_section/firing.rs
@@ -1021,13 +1021,13 @@ mod tests {
                     delay: 0.2,
                     amount: 3,
                 },
-                ITEM_PDC_ROUND.into(),
+                ItemRoleType::PdcRound.into(),
             ));
         let ship = app.world().get::(turret).unwrap().parent();
         app.world_mut().entity_mut(ship).insert(ShipInventory::new(
             &test_items(),
             10_000,
-            [(ITEM_PDC_ROUND.into(), 30)],
+            [(ItemRoleType::PdcRound.into(), 30)],
         ));
 
         // A full three-round magazine empties within one tick, and each
@@ -1046,7 +1046,7 @@ mod tests {
             app.world()
                 .get::(ship)
                 .unwrap()
-                .count(&ITEM_PDC_ROUND.into()),
+                .count(&ItemRoleType::PdcRound.into()),
             0,
             "the reload must drain the ship's stock"
         );
diff --git a/crates/nova_ship/src/sections/turret_section/setup.rs b/crates/nova_ship/src/sections/turret_section/setup.rs
index f60087fe2..98d43c0be 100644
--- a/crates/nova_ship/src/sections/turret_section/setup.rs
+++ b/crates/nova_ship/src/sections/turret_section/setup.rs
@@ -170,9 +170,10 @@ pub(super) fn insert_turret_section(
         // Kinetic and Pierce mounts load the same round: the mount, not
         // the item, decides the damage type.
         if let Some(reload) = config.reload.batch() {
-            commands
-                .entity(turret)
-                .insert(SectionReload::from_config(reload, ITEM_PDC_ROUND.into()));
+            commands.entity(turret).insert(SectionReload::from_config(
+                reload,
+                ItemRoleType::PdcRound.into(),
+            ));
         }
     }
 }
diff --git a/crates/nova_world/src/tests.rs b/crates/nova_world/src/tests.rs
index 994e3db44..6f337d07b 100644
--- a/crates/nova_world/src/tests.rs
+++ b/crates/nova_world/src/tests.rs
@@ -22,8 +22,8 @@ use nova_events::prelude::{Meters, Meters3, MetersPerSecond3};
 use nova_gameplay::{
     prelude::{
         Allegiance, AssetRef, DerelictShipMarker, GravityAffected, GravityWell, IntegrityEnvelope,
-        ItemDesignId, LootableShipMarker, ShipCredits, ShipInventory, ShipInventoryStock,
-        ITEM_HULL_PLATE,
+        ItemDesignId, ItemRoleType, LootableShipMarker, ShipCredits, ShipInventory,
+        ShipInventoryStock,
     },
     test_support::test_items,
 };
@@ -1039,7 +1039,7 @@ fn intact_and_derelict(input: SectorGenerationInput) -> SectorManifest {
     derelict.condition = SectorShipConditionType::Derelict;
     derelict.spawn.controller = SpaceshipController::None;
     derelict.spawn.lootable = true;
-    derelict.spawn.inventory = ShipInventoryStock::new([(ITEM_HULL_PLATE.into(), 5)]);
+    derelict.spawn.inventory = ShipInventoryStock::new([(ItemRoleType::HullPlate.into(), 5)]);
     derelict.spawn.credits = 15;
     manifest.ships.push(derelict);
     manifest
@@ -1100,7 +1100,7 @@ fn a_materialized_derelict_is_lootable_and_unflown_and_an_intact_ship_is_flown_b
                 "Solmar derelict, former civilian".to_string(),
                 true,
                 true,
-                vec![(ITEM_HULL_PLATE.into(), 5)],
+                vec![(ItemRoleType::HullPlate.into(), 5)],
                 15
             ),
         ]
diff --git a/crates/nova_world/src/tests/frozen.rs b/crates/nova_world/src/tests/frozen.rs
index bb505927e..2ab830ff4 100644
--- a/crates/nova_world/src/tests/frozen.rs
+++ b/crates/nova_world/src/tests/frozen.rs
@@ -13,8 +13,8 @@ use nova_events::prelude::{EntityId, Meters, Meters3, ScenarioAddressableMarker}
 use nova_gameplay::{
     prelude::{
         AssetRef, CargoCanister, CargoCanisterRuntimeId, DamageMark, DamageMarks, Health,
-        IntegrityDestroyMarker, PlayerSpaceshipMarker, SectionMarker, ShipCredits, ShipInventory,
-        ShipInventoryStock, SpaceshipRootMarker, TempEntity, ITEM_IRON_ORE, ITEM_WATER_ICE,
+        IntegrityDestroyMarker, ItemRoleType, PlayerSpaceshipMarker, SectionMarker, ShipCredits,
+        ShipInventory, ShipInventoryStock, SpaceshipRootMarker, TempEntity,
     },
     test_support::{settle, test_items, unfinished_integrity_physics_app},
 };
@@ -628,7 +628,7 @@ fn wreckable(input: SectorGenerationInput) -> SectorManifest {
     aft.position = Vec3::Z;
     aft.source = SectionSource::prototype(TEST_TURRET_SECTION_ID);
     design.sections.push(aft);
-    fought.spawn.inventory = ShipInventoryStock::new([(ITEM_IRON_ORE.into(), 10)]);
+    fought.spawn.inventory = ShipInventoryStock::new([(ItemRoleType::IronOre.into(), 10)]);
     let held = ship(
         sector_id(input.coord, "ship", 1),
         centre + Meters3::new(0.0, 0.0, quarter),
@@ -767,7 +767,7 @@ fn wreck_home(app: &mut App) -> (Entity, Entity) {
     world
         .get_mut::(fought)
         .expect("a ship has a hold")
-        .remove(&ITEM_IRON_ORE.into(), 7);
+        .remove(&ItemRoleType::IronOre.into(), 7);
     world.entity_mut(fought).insert(ShipCredits(0));
     settle(app);
     (rock, fought)
@@ -908,7 +908,7 @@ fn a_snapshot_of_the_live_world_reads_back_as_the_same_world() {
     let id = world
         .resource_mut::()
         .mint();
-    let contents = CargoCanister::new(&test_items(), &ITEM_IRON_ORE.into(), 4);
+    let contents = CargoCanister::new(&test_items(), &ItemRoleType::IronOre.into(), 4);
     world.spawn((
         cargo_canister(
             contents.clone(),
@@ -1181,8 +1181,8 @@ fn a_loose_canister_freezes_with_the_cell_it_drifts_in() {
         .resource_mut::()
         .mint();
     let items = test_items();
-    let mut contents = CargoCanister::new(&items, &ITEM_IRON_ORE.into(), 4);
-    contents.add(&items, &ITEM_WATER_ICE.into(), 2);
+    let mut contents = CargoCanister::new(&items, &ItemRoleType::IronOre.into(), 4);
+    contents.add(&items, &ItemRoleType::WaterIce.into(), 2);
     let at = Transform::from_translation(home.centre(edge).to_engine() + Vec3::Y * 40.0);
     let canister = world
         .spawn((
diff --git a/crates/nova_world_base/src/save/tests.rs b/crates/nova_world_base/src/save/tests.rs
index acb1bf087..3ba9bb0d7 100644
--- a/crates/nova_world_base/src/save/tests.rs
+++ b/crates/nova_world_base/src/save/tests.rs
@@ -10,11 +10,10 @@ use nova_gameplay::prelude::{
     nova_blast, resumed_lifetime, AssetRef, CargoCanister, CargoCanisterIdAllocator,
     CargoCanisterRuntimeId, ClockFreeze, DamageMarks, DamageType, FreezeOwner, FrozenRoundFlight,
     GameItems, Health, IntegrityDestroyMarker, ItemCategoryType, ItemDesign, ItemDesignId,
-    PlayerSpaceshipMarker, PointRotationOutput, ProjectileDamage, ProjectileOwner,
+    ItemRoleType, PlayerSpaceshipMarker, PointRotationOutput, ProjectileDamage, ProjectileOwner,
     RailgunSlugProjectileMarker, RoundRake, SavedBodyRef, SavedLifetime, SavedOwner,
     SavedSectionRef, SavedTargetRef, SectionMarker, ShipCredits, ShipInventory,
     SpaceshipRootMarker, TempEntity, TorpedoProjectileMarker, TurretBulletProjectileMarker,
-    ITEM_IRON_ORE, ITEM_TORPEDO,
 };
 use nova_scenario::prelude::{
     freeze_asteroid, freeze_ore_drop, freeze_ship, spaceship_scenario_object, AsteroidKind,
@@ -1847,7 +1846,11 @@ fn canister_ledger(cells: &[(SectorCoord, &[u64])]) -> FrozenSectors {
                     let canister = world
                         .spawn((
                             cargo_canister(
-                                CargoCanister::new(&items, &ItemDesignId::from(ITEM_IRON_ORE), 1),
+                                CargoCanister::new(
+                                    &items,
+                                    &ItemDesignId::from(ItemRoleType::IronOre),
+                                    1,
+                                ),
                                 Transform::IDENTITY,
                                 Vec3::ZERO,
                                 AssetRef::from("canister.glb#Scene0"),
@@ -2350,8 +2353,8 @@ fn a_world_whose_saved_stock_is_over_its_hold_or_canister_cap_is_refused_on_open
     let (folder, lock) = create_world(root.path(), "Stock").unwrap();
     drop(lock);
     let east = SectorCoord::new(1, 0, 0);
-    let iron = ron::to_string(&ItemDesignId::from(ITEM_IRON_ORE)).unwrap();
-    let torpedo = ron::to_string(&ItemDesignId::from(ITEM_TORPEDO)).unwrap();
+    let iron = ron::to_string(&ItemDesignId::from(ItemRoleType::IronOre)).unwrap();
+    let torpedo = ron::to_string(&ItemDesignId::from(ItemRoleType::Torpedo)).unwrap();
     // Read from RON, as a load reads them: the constructors refuse both.
     let player_with_hold = |capacity_g: u32| {
         let mut world = World::new();
diff --git a/crates/nova_world_base/src/sector_ships.rs b/crates/nova_world_base/src/sector_ships.rs
index 1953a18ca..98845c801 100644
--- a/crates/nova_world_base/src/sector_ships.rs
+++ b/crates/nova_world_base/src/sector_ships.rs
@@ -38,9 +38,7 @@
 use bevy::prelude::{Quat, Vec3};
 use nova_events::prelude::{Meters, Meters3, MetersPerSecond3};
 use nova_gameplay::prelude::{
-    Allegiance, Fnv32, GameItems, ItemDesignId, SeedStream, ShipInventoryStock, ITEM_CARBON_ORE,
-    ITEM_HULL_PLATE, ITEM_IRON_ORE, ITEM_PDC_ROUND, ITEM_RAIL_SLUG, ITEM_RATIONS,
-    ITEM_SALVAGED_PARTS, ITEM_STONE_ORE, ITEM_TORPEDO, ITEM_WATER_ICE,
+    Allegiance, Fnv32, GameItems, ItemDesignId, ItemRoleType, SeedStream, ShipInventoryStock,
 };
 use nova_scenario::prelude::{
     AIControllerConfig, ShipDesign, ShipDesignSource, SpaceshipConfig, SpaceshipController,
@@ -418,44 +416,44 @@ pub fn ship_stock(
         .count() as u64;
     let hold = hulls * u64::from(HULL_SECTION_CARGO_G);
     // Ammunition of every kind on an armed ship, whichever its weapons fire.
-    let mix: &[(&str, f32)] = match role {
+    let mix: &[(ItemRoleType, f32)] = match role {
         ShipRoleType::Industrial => &[
-            (ITEM_STONE_ORE, 2.0),
-            (ITEM_IRON_ORE, 2.0),
-            (ITEM_WATER_ICE, 2.0),
-            (ITEM_CARBON_ORE, 2.0),
-            (ITEM_SALVAGED_PARTS, 3.0),
-            (ITEM_RATIONS, 1.0),
+            (ItemRoleType::StoneOre, 2.0),
+            (ItemRoleType::IronOre, 2.0),
+            (ItemRoleType::WaterIce, 2.0),
+            (ItemRoleType::CarbonOre, 2.0),
+            (ItemRoleType::SalvagedParts, 3.0),
+            (ItemRoleType::Rations, 1.0),
         ],
         ShipRoleType::Civilian => &[
-            (ITEM_RATIONS, 5.0),
-            (ITEM_SALVAGED_PARTS, 3.0),
-            (ITEM_WATER_ICE, 1.0),
-            (ITEM_HULL_PLATE, 1.0),
+            (ItemRoleType::Rations, 5.0),
+            (ItemRoleType::SalvagedParts, 3.0),
+            (ItemRoleType::WaterIce, 1.0),
+            (ItemRoleType::HullPlate, 1.0),
         ],
         ShipRoleType::Scavenger => &[
-            (ITEM_PDC_ROUND, 2.0),
-            (ITEM_RAIL_SLUG, 1.0),
-            (ITEM_TORPEDO, 1.0),
-            (ITEM_HULL_PLATE, 3.0),
-            (ITEM_SALVAGED_PARTS, 3.0),
-            (ITEM_RATIONS, 1.0),
+            (ItemRoleType::PdcRound, 2.0),
+            (ItemRoleType::RailSlug, 1.0),
+            (ItemRoleType::Torpedo, 1.0),
+            (ItemRoleType::HullPlate, 3.0),
+            (ItemRoleType::SalvagedParts, 3.0),
+            (ItemRoleType::Rations, 1.0),
         ],
         ShipRoleType::Armored => &[
-            (ITEM_PDC_ROUND, 3.0),
-            (ITEM_RAIL_SLUG, 2.0),
-            (ITEM_TORPEDO, 1.0),
-            (ITEM_HULL_PLATE, 3.0),
-            (ITEM_SALVAGED_PARTS, 2.0),
-            (ITEM_RATIONS, 1.0),
+            (ItemRoleType::PdcRound, 3.0),
+            (ItemRoleType::RailSlug, 2.0),
+            (ItemRoleType::Torpedo, 1.0),
+            (ItemRoleType::HullPlate, 3.0),
+            (ItemRoleType::SalvagedParts, 2.0),
+            (ItemRoleType::Rations, 1.0),
         ],
     };
     let lightest = mix
         .iter()
         .map(|(item, _)| *item)
-        .min_by_key(|item| items.design(&ItemDesignId::from(*item)).mass_g)
+        .min_by_key(|item| items.role(*item).mass_g)
         .expect("every role mixes at least one item");
-    if hold < u64::from(items.design(&ItemDesignId::from(lightest)).mass_g) {
+    if hold < u64::from(items.role(lightest).mass_g) {
         return None;
     }
 
@@ -482,15 +480,15 @@ pub fn ship_stock(
     let mut stacks: Vec<(ItemDesignId, u32)> = drawn
         .into_iter()
         .filter_map(|(item, weight)| {
-            let id = ItemDesignId::from(item);
+            let design = items.role(item);
             let portion = (target as f64 * f64::from(weight / total)) as u64;
-            let units = portion / u64::from(items.design(&id).mass_g);
+            let units = portion / u64::from(design.mass_g);
             let units = u32::try_from(units).expect("a generated hold's units fit a u32");
-            (units > 0).then_some((id, units))
+            (units > 0).then_some((design.id.clone(), units))
         })
         .collect();
     if stacks.is_empty() {
-        stacks.push((ItemDesignId::from(lightest), 1));
+        stacks.push((items.role(lightest).id.clone(), 1));
     }
     Some(ShipInventoryStock::new(stacks))
 }
diff --git a/crates/nova_world_base/src/tests.rs b/crates/nova_world_base/src/tests.rs
index 3c3419c8b..a0a196f56 100644
--- a/crates/nova_world_base/src/tests.rs
+++ b/crates/nova_world_base/src/tests.rs
@@ -3,7 +3,7 @@ use std::{collections::BTreeMap, panic::AssertUnwindSafe};
 use bevy::ecs::system::RunSystemOnce;
 use nova_assets::prelude::{ContentCatalogDigest, LoadedSectionPack};
 use nova_events::prelude::{Meters, Meters3};
-use nova_gameplay::prelude::{Allegiance, Fnv64, ItemDesignId, ITEM_HULL_PLATE};
+use nova_gameplay::prelude::{Allegiance, Fnv64, ItemDesignId, ItemRoleType};
 use nova_scenario::prelude::{
     resolve_ship_design, GameShipDesigns, ScenarioConfig, SectionSource, ShipDesign,
     SpaceshipController, SpaceshipSectionConfig,
@@ -225,7 +225,7 @@ fn every_planned_ship_carries_goods_its_hold_fits_and_credits() {
                     plateless_wrecks += usize::from(
                         !stacks
                             .iter()
-                            .any(|(item, _)| item.as_str() == ITEM_HULL_PLATE),
+                            .any(|(item, _)| item.as_str() == ItemRoleType::HullPlate.id()),
                     );
                     1..=500
                 }
diff --git a/examples/playable/mine_and_sell.rs b/examples/playable/mine_and_sell.rs
index 85353227d..4af01b272 100644
--- a/examples/playable/mine_and_sell.rs
+++ b/examples/playable/mine_and_sell.rs
@@ -246,7 +246,7 @@ mod walk {
     const ROCK_NEAR_FACE: Vec3 = Vec3::new(1.0, 1.0, -13.3);
 
     /// The ore the rock's kind yields.
-    const ORE: &str = ITEM_STONE_ORE;
+    const ORE: &str = ItemRoleType::StoneOre;
 
     /// Real-seconds backstop for a state wait, long enough for lavapipe
     /// frames.
diff --git a/examples/playable/trade_loop.rs b/examples/playable/trade_loop.rs
index 05d0c0353..f381fcd02 100644
--- a/examples/playable/trade_loop.rs
+++ b/examples/playable/trade_loop.rs
@@ -69,7 +69,7 @@ const TRADER_NAME: &str = "Trader";
 const TRADER_POSITION: Meters3 = Meters3::new(0.0, 0.0, -55.0);
 
 /// What the tender carries at spawn: plates to sell.
-const TENDER_STOCK: &[(&str, u32)] = &[(ITEM_HULL_PLATE, 4)];
+const TENDER_STOCK: &[(&str, u32)] = &[(ItemRoleType::HullPlate.id(), 4)];
 /// The tender's credits at spawn: enough for Rations, short of two Salvaged
 /// parts after the plates sell.
 const TENDER_CREDITS: u32 = 100;
@@ -220,7 +220,7 @@ struct Ledger {
 #[cfg(feature = "debug")]
 const AFTER_BUY: (Ledger, Ledger) = (
     Ledger {
-        stacks: &[(ITEM_HULL_PLATE, 4), ("Rations", 1)],
+        stacks: &[(ItemRoleType::HullPlate.id(), 4), ("Rations", 1)],
         credits: 92,
     },
     Ledger {
@@ -238,7 +238,11 @@ const AFTER_SELL: (Ledger, Ledger) = (
         credits: 212,
     },
     Ledger {
-        stacks: &[(ITEM_HULL_PLATE, 4), ("Rations", 9), ("SalvagedParts", 2)],
+        stacks: &[
+            (ItemRoleType::HullPlate.id(), 4),
+            ("Rations", 9),
+            ("SalvagedParts", 2),
+        ],
         credits: 888,
     },
 );
diff --git a/examples/playable/wfc_arena.rs b/examples/playable/wfc_arena.rs
index b927f5231..a2292cd1f 100644
--- a/examples/playable/wfc_arena.rs
+++ b/examples/playable/wfc_arena.rs
@@ -1148,10 +1148,10 @@ fn combatant(
 ///
 /// The arena's loadout, not a balance rule: the shares sum to the whole hold,
 /// and the PDC rounds the guns spend fastest take half of it.
-const AMMO_HOLD_SHARE: [(&str, u32); 3] = [
-    (ITEM_PDC_ROUND, 50),
-    (ITEM_TORPEDO, 40),
-    (ITEM_RAIL_SLUG, 10),
+const AMMO_HOLD_SHARE: [(ItemRoleType, u32); 3] = [
+    (ItemRoleType::PdcRound, 50),
+    (ItemRoleType::Torpedo, 40),
+    (ItemRoleType::RailSlug, 10),
 ];
 
 /// The most full reloads a combatant carries of one ammunition, counted over
@@ -1172,18 +1172,18 @@ fn spare_ammo(hull: &ResolvedShipDesign, items: &GameItems) -> ShipInventoryStoc
             .sections
             .iter()
             .filter_map(|section| match (§ion.config.kind, item) {
-                (SectionKind::Turret(turret), ITEM_PDC_ROUND) => turret.ammunition.rounds(),
-                (SectionKind::Torpedo(bay), ITEM_TORPEDO) => bay.ammunition.rounds(),
-                (SectionKind::Railgun(lance), ITEM_RAIL_SLUG) => lance.ammunition.rounds(),
+                (SectionKind::Turret(turret), ItemRoleType::PdcRound) => turret.ammunition.rounds(),
+                (SectionKind::Torpedo(bay), ItemRoleType::Torpedo) => bay.ammunition.rounds(),
+                (SectionKind::Railgun(lance), ItemRoleType::RailSlug) => lance.ammunition.rounds(),
                 _ => None,
             })
             .map(u64::from)
             .sum();
-        let item = ItemDesignId::from(item);
-        let room = capacity_g * u64::from(share) / 100 / u64::from(items.design(&item).mass_g);
+        let design = items.role(item);
+        let room = capacity_g * u64::from(share) / 100 / u64::from(design.mass_g);
         let count = (magazines * u64::from(SPARE_MAGAZINES)).min(room);
         let count = u32::try_from(count).expect("a share of a u32 hold counts in a u32");
-        (count > 0).then_some((item, count))
+        (count > 0).then_some((design.id.clone(), count))
     }))
 }
 
@@ -3356,13 +3356,13 @@ mod reserve_tests {
     use super::*;
 
     /// The rounds every finite magazine of `item` holds across `hull`.
-    fn mounted_rounds(hull: &ResolvedShipDesign, item: &str) -> u32 {
+    fn mounted_rounds(hull: &ResolvedShipDesign, item: ItemRoleType) -> u32 {
         hull.sections
             .iter()
             .filter_map(|section| match (§ion.config.kind, item) {
-                (SectionKind::Turret(turret), ITEM_PDC_ROUND) => turret.ammunition.rounds(),
-                (SectionKind::Torpedo(bay), ITEM_TORPEDO) => bay.ammunition.rounds(),
-                (SectionKind::Railgun(lance), ITEM_RAIL_SLUG) => lance.ammunition.rounds(),
+                (SectionKind::Turret(turret), ItemRoleType::PdcRound) => turret.ammunition.rounds(),
+                (SectionKind::Torpedo(bay), ItemRoleType::Torpedo) => bay.ammunition.rounds(),
+                (SectionKind::Railgun(lance), ItemRoleType::RailSlug) => lance.ammunition.rounds(),
                 _ => None,
             })
             .sum()
@@ -3412,15 +3412,15 @@ mod reserve_tests {
                 stock.mass_g(&items)
             );
             for (item, share) in [
-                (ITEM_PDC_ROUND, 50),
-                (ITEM_TORPEDO, 40),
-                (ITEM_RAIL_SLUG, 10),
+                (ItemRoleType::PdcRound, 50),
+                (ItemRoleType::Torpedo, 40),
+                (ItemRoleType::RailSlug, 10),
             ] {
                 let spare = u64::from(2 * mounted_rounds(&resolved, item));
-                let room = capacity_g * share / 100 / u64::from(items.design(&item.into()).mass_g);
+                let room = capacity_g * share / 100 / u64::from(items.role(item).mass_g);
                 let carried = stock
                     .stacks()
-                    .find_map(|(held, count)| (held.as_str() == item).then_some(count))
+                    .find_map(|(held, count)| (held.as_str() == item.id()).then_some(count))
                     .unwrap_or(0);
                 assert_eq!(
                     u64::from(carried),
@@ -3518,17 +3518,20 @@ mod reserve_tests {
         let hull = small_hull(§ions, 3);
         assert_eq!(hull.cargo_capacity_g(), 300_000);
         assert!(
-            mounted_rounds(&hull, ITEM_TORPEDO) > 0,
+            mounted_rounds(&hull, ItemRoleType::Torpedo) > 0,
             "the bays are mounted"
         );
 
-        let pdc = 750.min(2 * mounted_rounds(&hull, ITEM_PDC_ROUND));
+        let pdc = 750.min(2 * mounted_rounds(&hull, ItemRoleType::PdcRound));
         assert_eq!(
             spare_ammo(&hull, &items)
                 .stacks()
                 .map(|(item, count)| (item.as_str(), count))
                 .collect::>(),
-            [(ITEM_PDC_ROUND, pdc), (ITEM_RAIL_SLUG, 1)],
+            [
+                (ItemRoleType::PdcRound.id(), pdc),
+                (ItemRoleType::RailSlug.id(), 1)
+            ],
         );
     }
 
@@ -3546,7 +3549,7 @@ mod reserve_tests {
                 .stacks()
                 .map(|(item, count)| (item.clone(), count)),
         );
-        let pdc_round = ItemDesignId::from(ITEM_PDC_ROUND);
+        let pdc_round = ItemDesignId::from(ItemRoleType::PdcRound);
         let reserve = inventory.count(&pdc_round);
         let (capacity, batch) = hull
             .sections
diff --git a/examples/playable/world_ships.rs b/examples/playable/world_ships.rs
index 05449b56d..1a5430de4 100644
--- a/examples/playable/world_ships.rs
+++ b/examples/playable/world_ships.rs
@@ -1585,7 +1585,7 @@ fn offer_canister_to(world: &mut World, ship: Entity) {
     let id = world.resource_mut::().mint();
     world.spawn((
         cargo_canister(
-            CargoCanister::new(&items, &ITEM_HULL_PLATE.into(), 1),
+            CargoCanister::new(&items, &ItemRoleType::HullPlate.into(), 1),
             Transform::from_translation(at).with_rotation(rotation),
             ship_velocity - normal * closing,
             config.canister_mesh.clone(),
@@ -1893,7 +1893,7 @@ fn check_wreck_intakes(world: &mut World) {
             .count();
         let taken = world
             .get::(root)
-            .map(|inventory| inventory.count(&ITEM_HULL_PLATE.into()))
+            .map(|inventory| inventory.count(&ItemRoleType::HullPlate.into()))
             .unwrap_or_default();
         assert_eq!(
             taken, 0,
@@ -2041,7 +2041,7 @@ fn canister_taken() -> std::sync::Arc bool + Send + Sync> {
                 query
                     .iter(world)
                     .next()
-                    .map(|inventory| inventory.count(&ITEM_HULL_PLATE.into()) >= 1)
+                    .map(|inventory| inventory.count(&ItemRoleType::HullPlate.into()) >= 1)
             })
             .unwrap_or(false)
     })
diff --git a/examples/screenshots/lesson_cargo.rs b/examples/screenshots/lesson_cargo.rs
index a760372e9..a43cca0b2 100644
--- a/examples/screenshots/lesson_cargo.rs
+++ b/examples/screenshots/lesson_cargo.rs
@@ -135,7 +135,7 @@ fn load_scene(mut commands: Commands, game_assets: Res, ships: Res(ship)
             .unwrap_or_else(|| panic!("lesson_cargo: {id} has no ShipInventory"))
-            .count(&ITEM_HULL_PLATE.into())
+            .count(&ItemRoleType::HullPlate.into())
     }
 
     /// Advance once the warship's hold holds `count` hull plates.
@@ -339,7 +339,7 @@ mod walk {
                 .is_some_and(|mut ships| {
                     ships
                         .iter(world)
-                        .any(|inventory| inventory.count(&ITEM_HULL_PLATE.into()) == count)
+                        .any(|inventory| inventory.count(&ItemRoleType::HullPlate.into()) == count)
                 })
         })
     }
@@ -524,7 +524,7 @@ mod walk {
         info!("lesson_cargo: the intake queues {queued:?}");
         assert_eq!(
             queued,
-            vec![vec![(ITEM_HULL_PLATE, DERELICT_PLATES)]],
+            vec![vec![(ItemRoleType::HullPlate, DERELICT_PLATES)]],
             "the Jettison queued one canister of every plate"
         );
     }
@@ -549,7 +549,7 @@ mod walk {
         assert_eq!(world.resource::().ejected, 1);
         assert_eq!(
             live,
-            vec![vec![(ITEM_HULL_PLATE, DERELICT_PLATES)]],
+            vec![vec![(ItemRoleType::HullPlate, DERELICT_PLATES)]],
             "the intake dropped one canister of every plate"
         );
     }
diff --git a/examples/screenshots/lesson_combat_battery.rs b/examples/screenshots/lesson_combat_battery.rs
index 651babc76..c4c875ffd 100644
--- a/examples/screenshots/lesson_combat_battery.rs
+++ b/examples/screenshots/lesson_combat_battery.rs
@@ -329,7 +329,7 @@ fn the_battery(
         // gauge only exists for a section that carries a `SectionAmmo`, so a
         // rig with unlimited guns has nothing for this lesson to photograph.
         player_hull,
-        ShipInventoryStock::new([(ITEM_PDC_ROUND.into(), PDC_RESERVE)]),
+        ShipInventoryStock::new([(ItemRoleType::PdcRound.into(), PDC_RESERVE)]),
     );
 
     let boats: Vec = BOAT_IDS
@@ -457,7 +457,7 @@ fn loaded_plus_reserve(world: &mut World) -> (u32, u32) {
     let reserve = world
         .get::(player)
         .expect("every ship root carries a ShipInventory")
-        .count(&ITEM_PDC_ROUND.into());
+        .count(&ItemRoleType::PdcRound.into());
     (loaded, reserve)
 }
 
diff --git a/examples/screenshots/lesson_combat_reach.rs b/examples/screenshots/lesson_combat_reach.rs
index c23d6322e..a9f3dcaf4 100644
--- a/examples/screenshots/lesson_combat_reach.rs
+++ b/examples/screenshots/lesson_combat_reach.rs
@@ -364,7 +364,7 @@ fn the_gun_range(game_assets: &GameAssets, ships: &GameShipDesigns) -> ScenarioC
         SpaceshipController::None,
         Some(Allegiance::Player),
         kit::catalog_ship(ships, "block_gunship"),
-        ShipInventoryStock::new([(ITEM_PDC_ROUND.into(), PDC_RESERVE)]),
+        ShipInventoryStock::new([(ItemRoleType::PdcRound.into(), PDC_RESERVE)]),
     );
     let boat = hollow::ship(
         BOAT_ID,
diff --git a/examples/screenshots/lesson_generated_world.rs b/examples/screenshots/lesson_generated_world.rs
index 059acf1a5..479575d38 100644
--- a/examples/screenshots/lesson_generated_world.rs
+++ b/examples/screenshots/lesson_generated_world.rs
@@ -162,7 +162,7 @@ struct Wreck {
     name: String,
     /// The item the walk takes and gives back: the lowest-ordered item the
     /// wreck's actual stock carries, picked once here. A generated wreck is no
-    /// longer guaranteed a [`ITEM_HULL_PLATE`], so Take and Give must work
+    /// longer guaranteed a [`ItemRoleType::HullPlate`], so Take and Give must work
     /// from whatever it actually holds, and never reroll it.
     item: ItemDesignId,
     /// The two holds' counts of `item` when the wreck was picked: the
diff --git a/examples/screenshots/lesson_interface_ship.rs b/examples/screenshots/lesson_interface_ship.rs
index cf0b20007..16372493b 100644
--- a/examples/screenshots/lesson_interface_ship.rs
+++ b/examples/screenshots/lesson_interface_ship.rs
@@ -207,7 +207,7 @@ fn hurt_the_turret(world: &mut World) {
         *inventory = ShipInventory::new(
             &items,
             inventory.capacity_g(),
-            [(ITEM_HULL_PLATE.into(), STOCKED_PLATES)],
+            [(ItemRoleType::HullPlate.into(), STOCKED_PLATES)],
         );
     }
 }
@@ -232,9 +232,9 @@ fn the_ship_spent_hull_plates() -> std::sync::Arc>()
             .is_some_and(|mut ships| {
-                ships
-                    .iter(world)
-                    .any(|inventory| inventory.count(&ITEM_HULL_PLATE.into()) < STOCKED_PLATES)
+                ships.iter(world).any(|inventory| {
+                    inventory.count(&ItemRoleType::HullPlate.into()) < STOCKED_PLATES
+                })
             })
     })
 }
diff --git a/examples/screenshots/screenshot_mining_beam.rs b/examples/screenshots/screenshot_mining_beam.rs
index a66d98db5..4803e9182 100644
--- a/examples/screenshots/screenshot_mining_beam.rs
+++ b/examples/screenshots/screenshot_mining_beam.rs
@@ -773,7 +773,7 @@ fn mining_script() -> nova_protocol::nova_debug::harness::AutopilotPlugin nova_protocol::nova_debug::harness::AutopilotPlugin(player)
                 .expect("the warship has a hold")
-                .count(&ITEM_STONE_ORE.into());
+                .count(&ItemRoleType::StoneOre.into());
             let in_canisters: u32 = canisters.iter().sum();
             info!(
                 "mining_beam: {flipped} corner(s) flipped, the field lost {}, \
diff --git a/examples/screenshots/screenshot_ship_repair.rs b/examples/screenshots/screenshot_ship_repair.rs
index c5868bd8f..47905a277 100644
--- a/examples/screenshots/screenshot_ship_repair.rs
+++ b/examples/screenshots/screenshot_ship_repair.rs
@@ -136,7 +136,7 @@ fn hurt_the_hull(world: &mut World) {
     *inventory = ShipInventory::new(
         &items,
         inventory.capacity_g(),
-        [(ITEM_HULL_PLATE.into(), STOCKED_PLATES)],
+        [(ItemRoleType::HullPlate.into(), STOCKED_PLATES)],
     );
 }
 
@@ -222,7 +222,7 @@ fn check_the_draft(world: &mut World) {
     let stock = ships
         .single(world)
         .expect("screenshot_ship_repair: one player inventory")
-        .count(&ITEM_HULL_PLATE.into());
+        .count(&ItemRoleType::HullPlate.into());
     assert_eq!(
         stock, STOCKED_PLATES,
         "screenshot_ship_repair: plates spent before Repair"
diff --git a/examples/systems/system_world_resume.rs b/examples/systems/system_world_resume.rs
index 1548c5443..afd5c922d 100644
--- a/examples/systems/system_world_resume.rs
+++ b/examples/systems/system_world_resume.rs
@@ -363,7 +363,7 @@ fn inject_fixture(world: &mut World) {
         .get::(rock)
         .expect("the fixture rock has a transform")
         .translation();
-    let canister_contents = CargoCanister::new(&items, &ITEM_IRON_ORE.into(), 3);
+    let canister_contents = CargoCanister::new(&items, &ItemRoleType::IronOre.into(), 3);
     let canister = world
         .spawn((
             cargo_canister(

From 8a3d74ca2ad89f3c876d9abb003a37bed73b5def Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 12:29:40 +0300
Subject: [PATCH 15/22] Hold the test item fixture to the base item catalog

Claim: `nova_gameplay::test_support::test_items` restates the ten base
rows of `item_catalog()`, so a price or mass retune in the builder left every
gameplay test green on the old table. The fixture cannot call the builder
(nova_authoring depends on nova_gameplay).

Change: `the_test_item_fixture_matches_the_base_catalog` in
crates/nova_authoring/src/base_content/items.rs asserts, in catalog order,
that the fixture holds every builder id with the same name, category, mass_g,
ask_cr and bid_cr, and no extra row. nova_authoring gains a dev-dependency on
nova_gameplay with `test-support` to reach the fixture. The authored values
stay in nova_authoring.

Verification: cargo check -p nova_authoring --all-targets clean; cargo test
-p nova_authoring --lib items 1 passed; a probe that changed the fixture's
hull plate bid to 31 failed the test with the row diff, then was reverted.

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_authoring/Cargo.toml              |  3 ++
 .../nova_authoring/src/base_content/items.rs  | 32 +++++++++++++++++++
 2 files changed, 35 insertions(+)

diff --git a/crates/nova_authoring/Cargo.toml b/crates/nova_authoring/Cargo.toml
index a3478bc35..83efafbfb 100644
--- a/crates/nova_authoring/Cargo.toml
+++ b/crates/nova_authoring/Cargo.toml
@@ -53,6 +53,9 @@ bevy_rand = { version = "0.15.0", default-features = false, features = [
 # up to a LIVE world: the dispatcher holds every handler while the scenario's
 # objects are still landing.
 nova_scenario = { path = "../nova_scenario", features = ["test-support"] }
+# The item parity test reads `test_support::test_items`, the fixture every
+# gameplay test prices and weighs items from, and holds it to the builder.
+nova_gameplay = { path = "../nova_gameplay", features = ["test-support"] }
 # The lint and RON-parity gates build throwaway content trees on disk.
 tempfile = "3"
 # `lesson_media.rs` decodes each committed demonstration to check its size
diff --git a/crates/nova_authoring/src/base_content/items.rs b/crates/nova_authoring/src/base_content/items.rs
index 653be469a..849211eac 100644
--- a/crates/nova_authoring/src/base_content/items.rs
+++ b/crates/nova_authoring/src/base_content/items.rs
@@ -125,3 +125,35 @@ pub(crate) fn item_catalog() -> Vec {
     )
     .collect()
 }
+
+#[cfg(test)]
+mod tests {
+    use nova_gameplay::test_support::test_items;
+
+    use super::*;
+
+    /// The gameplay test fixture restates the base rows, so a retune in this
+    /// builder that leaves the fixture behind fails here instead of leaving
+    /// every price and mass test green on old numbers.
+    #[test]
+    fn the_test_item_fixture_matches_the_base_catalog() {
+        let fixture = test_items();
+        let catalog = item_catalog();
+        assert_eq!(fixture.iter().count(), catalog.len());
+        for design in &catalog {
+            let row = |item: &ItemDesign| {
+                (
+                    item.name.clone(),
+                    item.category,
+                    item.mass_g,
+                    item.ask_cr,
+                    item.bid_cr,
+                )
+            };
+            let mirrored = fixture
+                .get(&design.id)
+                .unwrap_or_else(|| panic!("test_items lacks '{}'", design.id));
+            assert_eq!(row(mirrored), row(design), "'{}'", design.id);
+        }
+    }
+}

From 3b2b83784c8b3f5956da3883dc361bbc21467b84 Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 12:39:44 +0300
Subject: [PATCH 16/22] Lint a generated hull against the loaded item catalog

Claim: `hull_errors` ran the content gate with `GameItems::default()`, so a
generated hull that ever carried stock would have every real item reported
unknown and the editor's Generate verb would refuse every hull.

Change: `hull_errors(hull, sections, items)` takes the loaded catalog the way
`refuse_broken_ships` does; the comment that explained the empty catalog is
gone. The editor's `generate_ship` reads `Option>` beside the
sections and refuses with the same "content has not finished loading" note
when either is absent; `collapse` threads it through. The nova_wfc tests and
the editor's generate fixture build the catalog from
`nova_authoring::generation::build_items`.

Verification: cargo check -p nova_wfc -p nova_editor --all-targets clean;
cargo check --examples --keep-going clean; cargo test -p nova_wfc --lib 18
passed; cargo test -p nova_editor --lib generate passed (count in the
report).

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_editor/src/generate.rs       |  8 ++++++--
 crates/nova_editor/src/generate/tests.rs |  9 ++++++---
 crates/nova_wfc/src/check.rs             |  8 ++++----
 crates/nova_wfc/src/tests.rs             | 13 ++++++++++---
 4 files changed, 26 insertions(+), 12 deletions(-)

diff --git a/crates/nova_editor/src/generate.rs b/crates/nova_editor/src/generate.rs
index 43deac633..df37746b6 100644
--- a/crates/nova_editor/src/generate.rs
+++ b/crates/nova_editor/src/generate.rs
@@ -18,6 +18,7 @@
 //! made the player's.
 
 use bevy::{prelude::*, ui::InteractionDisabled, ui_widgets::Activate};
+use nova_gameplay::prelude::GameItems;
 use nova_scenario::prelude::ShipDesign;
 use nova_ship::prelude::{GameSections, GameStyles, SectionFootprint, SectionKind};
 use nova_ui::{
@@ -133,6 +134,7 @@ pub(crate) fn generate_ship(
     mut commands: Commands,
     seed: Res,
     sections: Option>,
+    items: Option>,
     styles: Option>,
     drawable: Query<(&PartChoice, Has)>,
     held: Query<&Children>,
@@ -147,7 +149,7 @@ pub(crate) fn generate_ship(
         says.refuse("go inside a ship to generate a hull into it");
         return;
     };
-    let Some(sections) = sections.as_deref() else {
+    let (Some(sections), Some(items)) = (sections.as_deref(), items.as_deref()) else {
         says.refuse("the content has not finished loading");
         return;
     };
@@ -169,6 +171,7 @@ pub(crate) fn generate_ship(
     let hull = match drawn_plan(sections, &drawn).and_then(|plan| {
         collapse(
             sections,
+            items,
             &plan,
             styles.as_deref(),
             seed.0,
@@ -380,6 +383,7 @@ fn holding(sections: &GameSections, plan: &WfcPlan) -> WfcGrid {
 /// still has that after a reroll.
 fn collapse(
     sections: &GameSections,
+    items: &GameItems,
     plan: &WfcPlan,
     styles: Option<&GameStyles>,
     seed: u64,
@@ -400,7 +404,7 @@ fn collapse(
         })
         .flatten();
     let hull = tiles.hull(seed, clad, style)?;
-    let errors = hull_errors(&hull, sections);
+    let errors = hull_errors(&hull, sections, items);
     match errors.first() {
         Some(first) => Err(format!(
             "the collapse built a hull the game refuses: {first}"
diff --git a/crates/nova_editor/src/generate/tests.rs b/crates/nova_editor/src/generate/tests.rs
index 6cad3fd80..98db41c4e 100644
--- a/crates/nova_editor/src/generate/tests.rs
+++ b/crates/nova_editor/src/generate/tests.rs
@@ -22,6 +22,7 @@ fn generate_app() -> (App, Entity) {
     app.insert_resource(GameSections(
         nova_authoring::generation::build_section_catalog(),
     ));
+    app.insert_resource(GameItems::new(nova_authoring::generation::build_items()));
     app.insert_resource(GameStyles(nova_authoring::generation::build_styles()));
     let scenario = app
         .world_mut()
@@ -635,21 +636,23 @@ fn a_reroll_leaves_the_ships_own_look_alone() {
 fn the_collapse_dresses_the_hull_in_the_ship_it_is_built_for() {
     let (app, _) = generate_app();
     let sections = app.world().resource::();
+    let items = app.world().resource::();
     let styles = app.world().resource::();
     let plan = WfcPlan::standard_hull();
     let second = styles.get(1).expect("more than one style").id.clone();
 
-    let bare = collapse(sections, &plan, Some(styles), 7, false, None).expect("a hull");
+    let bare = collapse(sections, items, &plan, Some(styles), 7, false, None).expect("a hull");
     assert!(
         !bare.presentation.skin,
         "a ship with its plating off gets a bare hull"
     );
     assert_eq!(bare.presentation.style, None, "and no style to wear it in");
 
-    let chosen = collapse(sections, &plan, Some(styles), 7, true, Some(&second)).expect("a hull");
+    let chosen =
+        collapse(sections, items, &plan, Some(styles), 7, true, Some(&second)).expect("a hull");
     assert_eq!(chosen.presentation.style.as_deref(), Some(second.as_str()));
 
-    let unchosen = collapse(sections, &plan, Some(styles), 7, true, None).expect("a hull");
+    let unchosen = collapse(sections, items, &plan, Some(styles), 7, true, None).expect("a hull");
     assert_eq!(
         unchosen.presentation.style.as_deref(),
         styles.first().map(|style| style.id.as_str()),
diff --git a/crates/nova_wfc/src/check.rs b/crates/nova_wfc/src/check.rs
index 6dd73d8c9..afd11e693 100644
--- a/crates/nova_wfc/src/check.rs
+++ b/crates/nova_wfc/src/check.rs
@@ -259,9 +259,9 @@ const LINT_SCENARIO_ID: &str = "generated_hull";
 /// The caller that has a hull and no scenario - the editor's Generate verb -
 /// asks this. It wraps the hull in the smallest scenario that can carry it and
 /// hands back what [`lint_errors`] found, so a hull is refused by exactly the
-/// rules that would refuse it in a mod file. The item catalog is empty: a
-/// generated hull carries no stock, so any item it names is reported unknown.
-pub fn hull_errors(hull: &ShipDesign, sections: &GameSections) -> Vec {
+/// rules that would refuse it in a mod file, against the `items` the game
+/// loaded.
+pub fn hull_errors(hull: &ShipDesign, sections: &GameSections, items: &GameItems) -> Vec {
     let spawn = EventActionConfig::SpawnScenarioObject(ScenarioObjectConfig {
         base: BaseScenarioObjectConfig {
             id: LINT_SCENARIO_ID.to_string(),
@@ -290,7 +290,7 @@ pub fn hull_errors(hull: &ShipDesign, sections: &GameSections) -> Vec {
             AssetRef::from(LINT_CUBEMAP),
         )
     };
-    lint_errors(&scenario, sections, &GameItems::default())
+    lint_errors(&scenario, sections, items)
 }
 
 /// The skybox the throwaway scenario names. The gate does not read it; a
diff --git a/crates/nova_wfc/src/tests.rs b/crates/nova_wfc/src/tests.rs
index a3f1974d8..215a60254 100644
--- a/crates/nova_wfc/src/tests.rs
+++ b/crates/nova_wfc/src/tests.rs
@@ -5,6 +5,7 @@
 //! content change rather than on a missing file.
 
 use bevy::prelude::{default, Quat, UVec3, Vec3};
+use nova_gameplay::prelude::GameItems;
 use nova_scenario::prelude::{SectionSource, ShipDesign, SpaceshipSectionConfig};
 use nova_ship::prelude::{
     BaseSectionConfig, GameSections, HullSectionConfig, LinkPoint, SectionCollider, SectionConfig,
@@ -21,6 +22,12 @@ use crate::{
 
 /// The shipped catalog and the standard plan read against each other - the
 /// same pair the running game builds.
+/// The shipped item catalog, which the content gate checks a hull's stock
+/// against.
+fn catalog_items() -> GameItems {
+    GameItems::new(nova_authoring::generation::build_items())
+}
+
 fn catalog_tiles() -> (GameSections, TileSet) {
     let sections = GameSections(nova_authoring::generation::build_section_catalog());
     let tiles = TileSet::build(§ions, &WfcPlan::standard_hull())
@@ -265,7 +272,7 @@ fn generated_hulls_clear_the_games_own_content_lint() {
     let (sections, tiles) = catalog_tiles();
     for seed in 0..8u64 {
         let hull = tiles.hull(seed, true, None).expect("the seed collapses");
-        let errors = hull_errors(&hull, §ions);
+        let errors = hull_errors(&hull, §ions, &catalog_items());
         assert!(
             errors.is_empty(),
             "seed {seed} produced content the game would refuse:\n  {}",
@@ -501,7 +508,7 @@ fn a_drive_bigger_than_one_cell_reaches_the_transom_of_every_hull() {
                 drives, 2,
                 "'{id}' is seeded once and mirrored, so seed {seed} owes the hull a PAIR"
             );
-            let errors = hull_errors(&hull, §ions);
+            let errors = hull_errors(&hull, §ions, &catalog_items());
             assert!(
                 errors.is_empty(),
                 "'{id}' seed {seed} produced content the game would refuse:\n  {}",
@@ -621,7 +628,7 @@ fn a_seeded_bow_gun_stands_on_the_nose_of_every_hull() {
                 gun.position.z
             );
         }
-        let errors = hull_errors(&hull, §ions);
+        let errors = hull_errors(&hull, §ions, &catalog_items());
         assert!(
             errors.is_empty(),
             "seed {seed} produced content the game would refuse:\n  {}",

From 52661d7cab0a5bbda46b40539cfdcc1ad9da3f23 Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 12:41:23 +0300
Subject: [PATCH 17/22] Walk a bundle's dependencies once for items and ship
 parts

Claim: lint_walk resolved a bundle's effective content twice.
`effective_item_bundles` walked `transitive_deps` from nova_mod_format
(depth-capped, deps-first order) for the item lint; `ship_part_packs` ran its
own stack DFS over a `by_id` map for the generated-ship snapshot. Both added
base as the implicit dependency and both followed declared dependencies
transitively, but the DFS had no depth cap and emitted packs in stack order,
so a chain deeper than MAX_DEP_DEPTH would lint its items against base and
the bundle alone while its ship parts walked the whole chain.

Change: one `effective_bundles(bundle, all) -> Vec<&WalkedBundle>`, the
former item walk, feeds both. `ship_part_packs(bundle, all)` maps each
effective bundle to a `ShipPartPack` that still names its declared
dependencies plus base, so a dependency missing from the walk is still
reported by the snapshot as before. The `transitive_deps` rule won: it is
the mod format's own walk, the one `item_pack_faults` and the runtime merge
resolve against, and `ShipPartSnapshot::build` accepts packs in any order,
so the DFS's order carried nothing. `repo_ship_part_packs` keeps its
signature and id-ordered result.

Verification: cargo check -p nova_authoring --all-targets clean; cargo
check --examples --keep-going clean; cargo test -p nova_authoring --lib
lint_walk 10 passed; cargo test -p nova_authoring --test generated_wrecks 2
passed.

Co-Authored-By: Claude Fable 5.1 
Claude-Session: https://claude.ai/code/session_012DRN1MeaNCeRLWpWp1nBQA
---
 crates/nova_authoring/src/lint_walk.rs | 89 +++++++++++---------------
 1 file changed, 36 insertions(+), 53 deletions(-)

diff --git a/crates/nova_authoring/src/lint_walk.rs b/crates/nova_authoring/src/lint_walk.rs
index b07785b8d..381f368d8 100644
--- a/crates/nova_authoring/src/lint_walk.rs
+++ b/crates/nova_authoring/src/lint_walk.rs
@@ -279,7 +279,7 @@ fn lint_bundle(bundle: &WalkedBundle, all: &[WalkedBundle]) -> Vec<(String, Lint
     // Visible items: the catalog the merge builds when this bundle loads with
     // what it depends on, a dependent's replacement winning.
     let known_items = GameItems::new(
-        effective_item_bundles(bundle, all)
+        effective_bundles(bundle, all)
             .into_iter()
             .flat_map(|walked| walked.items.iter().cloned()),
     );
@@ -571,28 +571,27 @@ pub fn lint_target(dir: &Path) -> Vec<(String, LintIssue)> {
 /// If the repo tree holds no bundle with one of the ids.
 pub fn repo_ship_part_packs(ids: &[&str]) -> Vec {
     let all = walk_repo_bundles();
-    let by_id: HashMap<&str, &WalkedBundle> = all.iter().map(|b| (b.id.as_str(), b)).collect();
     let mut packs = BTreeMap::new();
     for id in ids {
-        let bundle = by_id
-            .get(id)
+        let bundle = all
+            .iter()
+            .find(|bundle| bundle.id == *id)
             .unwrap_or_else(|| panic!("the repo tree holds no bundle '{id}'"));
-        for pack in ship_part_packs(bundle, &by_id) {
+        for pack in ship_part_packs(bundle, &all) {
             packs.entry(pack.id.clone()).or_insert(pack);
         }
     }
     packs.into_values().collect()
 }
 
-/// The bundles whose items load with `bundle`: base, every bundle it depends
-/// on directly or through another, and itself, each after every bundle it
-/// depends on, so a later item replaces an earlier one as the merge does. A
-/// dependency missing from the walk is left out; a chain too deep to walk
-/// keeps only base and the bundle, and [`item_pack_faults`] names it.
-fn effective_item_bundles<'a>(
-    bundle: &WalkedBundle,
-    all: &'a [WalkedBundle],
-) -> Vec<&'a WalkedBundle> {
+/// The bundles whose content loads with `bundle`: base, every bundle it
+/// depends on directly or through another, and itself, each after every
+/// bundle it depends on, so a later item or section replaces an earlier one
+/// as the merge does. One walk for items and ship parts, so the two lints
+/// cannot disagree on a mod chain. A dependency missing from the walk is left
+/// out; a chain deeper than the mod format walks keeps only base and the
+/// bundle, and [`item_pack_faults`] names it.
+fn effective_bundles<'a>(bundle: &WalkedBundle, all: &'a [WalkedBundle]) -> Vec<&'a WalkedBundle> {
     let graph: DepGraph = all
         .iter()
         .map(|walked| (walked.id.clone(), walked.manifest.meta.dependencies.clone()))
@@ -617,42 +616,26 @@ fn effective_item_bundles<'a>(
     bundles
 }
 
-/// `bundle`'s effective section catalog as snapshot packs: base, every bundle
-/// it depends on directly or through another, and itself. `base` is every
-/// mod's implicit dependency. A dependency missing from the walk is left for
-/// the snapshot to name.
-fn ship_part_packs(
-    bundle: &WalkedBundle,
-    by_id: &HashMap<&str, &WalkedBundle>,
-) -> Vec {
-    let dependencies = |walked: &WalkedBundle| -> Vec {
-        let mut dependencies: BTreeSet =
-            walked.manifest.meta.dependencies.iter().cloned().collect();
-        if walked.id != BASE_MOD_ID {
-            dependencies.insert(BASE_MOD_ID.to_string());
-        }
-        dependencies.into_iter().collect()
-    };
-    let mut packs = Vec::new();
-    let mut seen = HashSet::new();
-    let mut pending = vec![bundle];
-    while let Some(walked) = pending.pop() {
-        if !seen.insert(walked.id.as_str()) {
-            continue;
-        }
-        let dependencies = dependencies(walked);
-        pending.extend(
-            dependencies
-                .iter()
-                .filter_map(|dependency| by_id.get(dependency.as_str()).copied()),
-        );
-        packs.push(ShipPartPack {
-            id: walked.id.clone(),
-            dependencies,
-            sections: walked.sections.clone(),
-        });
-    }
-    packs
+/// `bundle`'s effective section catalog as snapshot packs: one per bundle of
+/// [`effective_bundles`], each naming its declared dependencies and `base`,
+/// every mod's implicit dependency, so a dependency missing from the walk is
+/// left for the snapshot to name.
+fn ship_part_packs(bundle: &WalkedBundle, all: &[WalkedBundle]) -> Vec {
+    effective_bundles(bundle, all)
+        .into_iter()
+        .map(|walked| {
+            let mut dependencies: BTreeSet =
+                walked.manifest.meta.dependencies.iter().cloned().collect();
+            if walked.id != BASE_MOD_ID {
+                dependencies.insert(BASE_MOD_ID.to_string());
+            }
+            ShipPartPack {
+                id: walked.id.clone(),
+                dependencies: dependencies.into_iter().collect(),
+                sections: walked.sections.clone(),
+            }
+        })
+        .collect()
 }
 
 /// The flight-rig input overlaps in one scenario: every player
@@ -767,11 +750,11 @@ fn build_report(
     let base_id = BASE_MOD_ID.to_string();
     let base_ship_part_faults = by_id
         .get(BASE_MOD_ID)
-        .and_then(|base| ShipPartSnapshot::build(&ship_part_packs(base, &by_id)).err())
+        .and_then(|base| ShipPartSnapshot::build(&ship_part_packs(base, all)).err())
         .unwrap_or_default();
     let mut ship_part_faults: BTreeSet<(String, String, String)> = BTreeSet::new();
     for bundle in all.iter().filter(|b| report_ids.contains(&b.id)) {
-        let Err(faults) = ShipPartSnapshot::build(&ship_part_packs(bundle, &by_id)) else {
+        let Err(faults) = ShipPartSnapshot::build(&ship_part_packs(bundle, all)) else {
             continue;
         };
         for fault in faults {
@@ -823,7 +806,7 @@ fn build_report(
     let loaded: HashSet<&str> = all
         .iter()
         .filter(|b| report_ids.contains(&b.id))
-        .flat_map(|bundle| effective_item_bundles(bundle, all))
+        .flat_map(|bundle| effective_bundles(bundle, all))
         .map(|walked| walked.id.as_str())
         .collect();
     let packs: Vec = all

From 45fcf7f49179beb909f4797e272fd333490b8a5e Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 14:15:06 +0300
Subject: [PATCH 18/22] Add save status as status item in UI

---
 CHANGELOG.md                                  |   6 +-
 crates/nova_menu/src/lib.rs                   |   4 +-
 crates/nova_menu/src/save_status.rs           | 103 ++++++++++++-----
 crates/nova_menu/src/tests/leave.rs           |  72 ++++++++++++
 .../nova_ship/src/sections/damage_sparks.rs   |  14 ++-
 crates/nova_world_base/src/save/session.rs    |  19 ++--
 crates/nova_world_base/src/save/tests.rs      | 104 ++++++++++++++++--
 crates/nova_world_base/src/save/transients.rs |  21 ++--
 examples/systems/system_world_resume.rs       |  19 ++--
 web/src/wiki/getting-started.md               |   6 +-
 10 files changed, 295 insertions(+), 73 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8cfb29f6..ad62a0191 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,9 +47,9 @@ does NOT get an entry. Another page that states a different rule is wrong.
 
 - GOTO's predicted path now curves for a coasting or tumbling target, gravity
   included. It hides while the target changes course or spin; the guide stays.
-- Create names the world, New World by default. Load checks saved worlds in
-  the background, says why one cannot load, and deletes on confirm. A status
-  line shows the last save; leaving offers a retry.
+- Create names worlds; New World is the default. Load checks saves, reports
+  refusals, and deletes on confirm. Save notices show progress, success or
+  failure; `~` hides them. Leaving offers a retry.
 
 ### Web & Platform
 
diff --git a/crates/nova_menu/src/lib.rs b/crates/nova_menu/src/lib.rs
index 360f9925b..e1cbba854 100644
--- a/crates/nova_menu/src/lib.rs
+++ b/crates/nova_menu/src/lib.rs
@@ -111,7 +111,7 @@ use pause::{
 };
 use portal::{drive_update_choreography, UpdateRequested};
 #[cfg(not(target_arch = "wasm32"))]
-use save_status::sync_save_status_line;
+use save_status::sync_save_notice;
 pub use scenarios::NewGameScenario;
 use scenarios::{
     poll_scenario_thumbnail, refresh_scenario_details, refresh_scenarios_list,
@@ -487,7 +487,7 @@ impl Plugin for NovaMenuPlugin {
         #[cfg(not(target_arch = "wasm32"))]
         app.add_systems(
             Update,
-            sync_save_status_line.run_if(in_state(GameStates::Playing)),
+            sync_save_notice.run_if(in_state(GameStates::Playing)),
         );
 
         // `resource_exists`-gated - headless rigs without the scenario
diff --git a/crates/nova_menu/src/save_status.rs b/crates/nova_menu/src/save_status.rs
index 03b05d7ee..457bd37ef 100644
--- a/crates/nova_menu/src/save_status.rs
+++ b/crates/nova_menu/src/save_status.rs
@@ -1,44 +1,81 @@
-//! The open world's save status line: a small top-right readout of what the
-//! last save of the open [`WorldSaveSession`] did.
+//! A transient save notice while an open world saves or reports a failure.
+
+use std::time::Duration;
 
 use bevy::prelude::*;
 use nova_gameplay::prelude::GameStates;
+use nova_hud::prelude::HudTier;
 use nova_ui::{prelude::HUD_Z, theme::UiColor, widget::ThemedText};
 use nova_world_base::prelude::{WorldSaveSession, WorldSaveStatus};
 
-/// Marker for the one status line [`sync_save_status_line`] keeps.
+/// Marker for the one notice [`sync_save_notice`] keeps.
 #[derive(Component)]
-pub(crate) struct SaveStatusLine;
+pub(crate) struct SaveNotice;
 
-/// The line's text and theme colour for `status`.
-fn status_text(status: &WorldSaveStatus) -> (String, UiColor) {
+/// The notice's text and theme colour for `status`.
+fn status_text(status: &WorldSaveStatus, show_saved: bool) -> Option<(String, UiColor)> {
     match status {
-        WorldSaveStatus::Unsaved => ("Not saved yet".to_string(), UiColor::Label),
-        WorldSaveStatus::Saved { .. } => ("World saved".to_string(), UiColor::Label),
-        WorldSaveStatus::Writing => ("Saving world...".to_string(), UiColor::Label),
-        WorldSaveStatus::Waiting(why) => (format!("Waiting to save: {why}"), UiColor::Label),
-        WorldSaveStatus::Failed(err) => (format!("SAVE FAILED: {err}"), UiColor::Danger),
+        WorldSaveStatus::Unsaved => None,
+        WorldSaveStatus::Saved { .. } if show_saved => {
+            Some(("World saved".to_string(), UiColor::Label))
+        }
+        WorldSaveStatus::Saved { .. } => None,
+        WorldSaveStatus::Writing | WorldSaveStatus::Waiting(_) => {
+            Some(("Saving world...".to_string(), UiColor::Label))
+        }
+        WorldSaveStatus::Failed(err) => Some((format!("SAVE FAILED: {err}"), UiColor::Danger)),
     }
 }
 
-/// Spawn, update or despawn the one save status line: present while a
-/// [`WorldSaveSession`] exists, gone otherwise. A line is written only when
-/// what it says changes, so the text is not laid out again each frame.
-pub(crate) fn sync_save_status_line(
+/// Show a save in progress, a new success for three real seconds, or a
+/// failure until the next attempt. An opened world's old success is silent.
+pub(crate) fn sync_save_notice(
     mut commands: Commands,
     session: Option>,
-    mut lines: Query<(Entity, &mut Text, &mut ThemedText), With>,
+    time: Res>,
+    mut previous: Local>,
+    mut saved_at: Local>,
+    mut notices: Query<(Entity, &mut Text, &mut ThemedText), With>,
 ) {
     let Some(session) = session else {
-        for (entity, ..) in &lines {
+        *previous = None;
+        *saved_at = None;
+        for (entity, ..) in ¬ices {
+            commands.entity(entity).despawn();
+        }
+        return;
+    };
+    if session.is_added() {
+        *previous = None;
+        *saved_at = None;
+    }
+    let status = session.status();
+    if previous.as_ref() != Some(status) {
+        if matches!(status, WorldSaveStatus::Saved { .. })
+            && previous.as_ref().is_some_and(|status| {
+                matches!(
+                    status,
+                    WorldSaveStatus::Waiting(_) | WorldSaveStatus::Writing
+                )
+            })
+        {
+            *saved_at = Some(time.elapsed());
+        } else if !matches!(status, WorldSaveStatus::Saved { .. }) {
+            *saved_at = None;
+        }
+        *previous = Some(status.clone());
+    }
+    let show_saved =
+        saved_at.is_some_and(|at| time.elapsed().saturating_sub(at) < Duration::from_secs(3));
+    let Some((text, color)) = status_text(status, show_saved) else {
+        for (entity, ..) in ¬ices {
             commands.entity(entity).despawn();
         }
         return;
     };
-    let (text, color) = status_text(session.status());
-    if let Some((_, mut line_text, mut themed)) = lines.iter_mut().next() {
-        if line_text.0 != text {
-            line_text.0 = text;
+    if let Some((_, mut notice_text, mut themed)) = notices.iter_mut().next() {
+        if notice_text.0 != text {
+            notice_text.0 = text;
         }
         if themed.color != color {
             *themed = ThemedText::new(color);
@@ -46,14 +83,14 @@ pub(crate) fn sync_save_status_line(
         return;
     }
     commands.spawn((
-        Name::new("Save Status Line"),
-        SaveStatusLine,
-        // This system runs only in Playing, so it cannot remove the line
-        // after a leave to the menu.
+        Name::new("Save Notice"),
+        SaveNotice,
+        HudTier::Status,
+        Visibility::Inherited,
         DespawnOnExit(GameStates::Playing),
         Node {
             position_type: PositionType::Absolute,
-            top: px(30),
+            bottom: px(30),
             right: px(10),
             ..default()
         },
@@ -67,3 +104,17 @@ pub(crate) fn sync_save_status_line(
         ThemedText::new(color),
     ));
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn an_unsaved_or_preexisting_saved_world_has_no_notice() {
+        assert!(status_text(&WorldSaveStatus::Unsaved, false).is_none());
+        assert!(status_text(&WorldSaveStatus::Saved { generation: 1 }, false).is_none());
+        assert!(status_text(&WorldSaveStatus::Saved { generation: 2 }, true).is_some());
+        assert!(status_text(&WorldSaveStatus::Waiting(String::new()), false).is_some());
+        assert!(status_text(&WorldSaveStatus::Writing, false).is_some());
+    }
+}
diff --git a/crates/nova_menu/src/tests/leave.rs b/crates/nova_menu/src/tests/leave.rs
index aba604c39..717920826 100644
--- a/crates/nova_menu/src/tests/leave.rs
+++ b/crates/nova_menu/src/tests/leave.rs
@@ -11,6 +11,7 @@ use bevy::{
     window::WindowCloseRequested,
 };
 use nova_gameplay::prelude::*;
+use nova_hud::prelude::HudTier;
 use nova_scenario::prelude::*;
 use nova_ship::prelude::{
     thaw_torpedo, FrozenTorpedo, SavedTorpedoTarget, TorpedoArming, TorpedoSectionConfig,
@@ -76,6 +77,77 @@ fn session(app: &App) -> Option<&WorldSaveSession> {
     app.world().get_resource::()
 }
 
+/// A new success expires on real time; a failed leave stays visible until
+/// another attempt, without changing the leave controls.
+#[test]
+fn the_save_notice_shows_new_saves_for_three_real_seconds_and_keeps_failures() {
+    let (mut app, root, player) = saved_world();
+    let notice = |app: &mut App| {
+        find_named(app, "Save Notice")
+            .map(|entity| app.world().get::(entity).unwrap().0.clone())
+    };
+    app.update();
+    assert_eq!(notice(&mut app).as_deref(), Some("World saved"));
+    let notice_entity = find_named(&mut app, "Save Notice").unwrap();
+    assert_eq!(
+        app.world().get::(notice_entity),
+        Some(&HudTier::Status)
+    );
+    assert_eq!(
+        app.world().get::(notice_entity),
+        Some(&Visibility::Inherited)
+    );
+    for _ in 0..180 {
+        app.update();
+    }
+    assert_eq!(notice(&mut app).as_deref(), Some("World saved"));
+    for _ in 0..10 {
+        app.update();
+    }
+    assert_eq!(notice(&mut app), None);
+
+    app.world_mut().entity_mut(player).despawn();
+    app.world_mut()
+        .resource_mut::()
+        .request_leave();
+    update_until(&mut app, "the failed leave save", |app| {
+        session(app).is_some_and(|session| matches!(session.status(), WorldSaveStatus::Failed(_)))
+    });
+    app.update();
+    assert!(notice(&mut app)
+        .as_deref()
+        .is_some_and(|text| text.starts_with("SAVE FAILED:")));
+    for _ in 0..190 {
+        app.update();
+    }
+    assert!(notice(&mut app)
+        .as_deref()
+        .is_some_and(|text| text.starts_with("SAVE FAILED:")));
+    app.world_mut()
+        .resource_mut::()
+        .request_leave();
+    assert!(matches!(
+        session(&app).unwrap().status(),
+        WorldSaveStatus::Waiting(_)
+    ));
+    app.world_mut().remove_resource::();
+    let (folder, lock, header, _) = open_world(
+        root.path(),
+        "leave",
+        app.world()
+            .resource::(),
+        app.world().resource::(),
+    )
+    .unwrap();
+    app.insert_resource(WorldSaveSession::opened(folder, lock, &header));
+    app.update();
+    assert_eq!(
+        notice(&mut app),
+        None,
+        "an opened world's old save stays silent"
+    );
+}
+
 fn update_until(app: &mut App, what: &str, done: impl Fn(&App) -> bool) {
     for _ in 0..2_000 {
         if done(app) {
diff --git a/crates/nova_ship/src/sections/damage_sparks.rs b/crates/nova_ship/src/sections/damage_sparks.rs
index d84c8526a..a095b1231 100644
--- a/crates/nova_ship/src/sections/damage_sparks.rs
+++ b/crates/nova_ship/src/sections/damage_sparks.rs
@@ -25,9 +25,9 @@ use avian3d::prelude::{LinearVelocity, RigidBody};
 use bevy::prelude::*;
 use nova_gameplay::prelude::{unit_sphere_point, DamageLevel, Fnv32, TempEntity};
 
-/// `DamageSparks` and `DamageSparksPlugin`.
+/// `DamageSparkMarker`, `DamageSparks` and `DamageSparksPlugin`.
 pub mod prelude {
-    pub use super::{DamageSparks, DamageSparksPlugin};
+    pub use super::{DamageSparkMarker, DamageSparks, DamageSparksPlugin};
 }
 
 /// The level below which a section throws nothing at all.
@@ -77,6 +77,14 @@ pub struct DamageSparks {
     pub thrown: u32,
 }
 
+/// A spark in flight off a failing section. A range can count these to
+/// assert a burst happened; the save classifies a live one by this marker, as
+/// [`ImpactSparkMarker`](nova_gameplay::prelude::ImpactSparkMarker) names the
+/// other spark.
+#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
+#[reflect(Component)]
+pub struct DamageSparkMarker;
+
 /// Which way the `nth` spark off `section` is thrown: a point on the unit
 /// sphere, spread by a hash of the two.
 ///
@@ -133,6 +141,7 @@ impl Plugin for DamageSparksPlugin {
         trace!("DamageSparksPlugin: build");
 
         app.register_type::();
+        app.register_type::();
         app.init_resource::();
         app.add_systems(Update, throw_damage_sparks);
     }
@@ -180,6 +189,7 @@ fn throw_damage_sparks(
 
         commands.spawn((
             Name::new("Damage Spark"),
+            DamageSparkMarker,
             Mesh3d(assets.mesh.clone()),
             MeshMaterial3d(assets.material.clone()),
             Transform::from_translation(origin + direction * 0.4),
diff --git a/crates/nova_world_base/src/save/session.rs b/crates/nova_world_base/src/save/session.rs
index 70b664d71..1026eecd3 100644
--- a/crates/nova_world_base/src/save/session.rs
+++ b/crates/nova_world_base/src/save/session.rs
@@ -51,7 +51,7 @@ pub enum SaveReason {
     Leave,
 }
 
-/// What the last save did, for the status line and the leave overlay.
+/// What the last save did, for the save notice and the leave overlay.
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum WorldSaveStatus {
     /// A created world that has no save yet.
@@ -199,11 +199,17 @@ impl WorldSaveSession {
         self.writer.is_none() && self.wanted.is_none()
     }
 
+    /// Record one failed save for the player and the log.
+    fn fail(&mut self, reason: String) {
+        error!("nova_world_base: the world save failed: {reason}");
+        self.status = WorldSaveStatus::Failed(reason);
+    }
+
     /// Drop the wanted save. A dropped leave save is a failure the player
     /// must see; a dropped crossing save keeps the status it had.
     fn drop_wanted(&mut self, reason: &str) {
         if self.wanted.take() == Some(SaveReason::Leave) {
-            self.status = WorldSaveStatus::Failed(reason.to_string());
+            self.fail(reason.to_string());
         }
         self.settling_frames = 0;
     }
@@ -388,7 +394,7 @@ pub(crate) fn snapshot_world(world: &mut World) {
                 let reason = format!("{unsettled}; it did not settle");
                 session.wanted = None;
                 session.settling_frames = 0;
-                session.status = WorldSaveStatus::Failed(reason);
+                session.fail(reason);
             } else {
                 session.status = WorldSaveStatus::Waiting(unsettled.to_string());
             }
@@ -403,7 +409,7 @@ pub(crate) fn snapshot_world(world: &mut World) {
             let mut session = world.resource_mut::();
             session.wanted = None;
             session.settling_frames = 0;
-            session.status = WorldSaveStatus::Failed(unowned.to_string());
+            session.fail(unowned.to_string());
             return;
         }
     };
@@ -500,7 +506,7 @@ pub(crate) fn snapshot_world(world: &mut World) {
     if let Err(error) = checked {
         session.wanted = None;
         session.settling_frames = 0;
-        session.status = WorldSaveStatus::Failed(error.to_string());
+        session.fail(error.to_string());
         return;
     }
     let folder = session.folder.clone();
@@ -549,8 +555,7 @@ pub(crate) fn poll_world_writer(mut session: ResMut) {
             session.status = WorldSaveStatus::Saved { generation };
         }
         Err(reason) => {
-            error!("nova_world_base: the world save failed: {reason}");
-            session.status = WorldSaveStatus::Failed(reason);
+            session.fail(reason);
         }
     }
 }
diff --git a/crates/nova_world_base/src/save/tests.rs b/crates/nova_world_base/src/save/tests.rs
index 3ba9bb0d7..28e40579d 100644
--- a/crates/nova_world_base/src/save/tests.rs
+++ b/crates/nova_world_base/src/save/tests.rs
@@ -8,9 +8,10 @@ use nova_assets::prelude::{ContentCatalogDigest, LoadedSectionPack, LoadedSectio
 use nova_events::prelude::EntityId;
 use nova_gameplay::prelude::{
     nova_blast, resumed_lifetime, AssetRef, CargoCanister, CargoCanisterIdAllocator,
-    CargoCanisterRuntimeId, ClockFreeze, DamageMarks, DamageType, FreezeOwner, FrozenRoundFlight,
-    GameItems, Health, IntegrityDestroyMarker, ItemCategoryType, ItemDesign, ItemDesignId,
-    ItemRoleType, PlayerSpaceshipMarker, PointRotationOutput, ProjectileDamage, ProjectileOwner,
+    CargoCanisterRuntimeId, ClockFreeze, DamageLevel, DamageMarks, DamageType, FreezeOwner,
+    FrozenRoundFlight, GameItems, Health, ImpactSparkMarker, ImpactSparkPlugin, ImpactSparks,
+    IntegrityDestroyMarker, ItemCategoryType, ItemDesign, ItemDesignId, ItemRoleType,
+    PlayerSpaceshipMarker, PointRotationOutput, ProjectileDamage, ProjectileOwner,
     RailgunSlugProjectileMarker, RoundRake, SavedBodyRef, SavedLifetime, SavedOwner,
     SavedSectionRef, SavedTargetRef, SectionMarker, ShipCredits, ShipInventory,
     SpaceshipRootMarker, TempEntity, TorpedoProjectileMarker, TurretBulletProjectileMarker,
@@ -22,13 +23,14 @@ use nova_scenario::prelude::{
 };
 use nova_ship::prelude::{
     cargo_canister, freeze_canister, freeze_wreck_fragment, thaw_round, thaw_torpedo,
-    BaseSectionConfig, BodyRadius, CameraView, CargoIntakeEjectionQueue, ChaseZoom, DestroySound,
-    FrozenRound, FrozenTorpedo, HullSectionConfig, LockSignature, RoundSourceType,
-    SavedTorpedoTarget, SectionAnimations, SectionBuildConfig, SectionConfig, SectionKind,
-    SectionReload, SectionReloadConfig, ShipWreckFragmentMarker, SpaceshipCameraController,
-    SpaceshipCameraInputMarker, SpaceshipCameraNormalInputMarker, SuspendedSectionAmmo,
-    TorpedoArming, TorpedoControllerMarker, TorpedoSectionConfig, TorpedoTargetChosen,
-    TorpedoTargetEntity, TorpedoTargetPosition, TorpedoWeave,
+    BaseSectionConfig, BodyRadius, CameraView, CargoIntakeEjectionQueue, ChaseZoom,
+    DamageSparkMarker, DamageSparks, DamageSparksPlugin, DestroySound, FrozenRound, FrozenTorpedo,
+    HullSectionConfig, LockSignature, RoundSourceType, SavedTorpedoTarget, SectionAnimations,
+    SectionBuildConfig, SectionConfig, SectionKind, SectionReload, SectionReloadConfig,
+    ShipWreckFragmentMarker, SpaceshipCameraController, SpaceshipCameraInputMarker,
+    SpaceshipCameraNormalInputMarker, SuspendedSectionAmmo, TorpedoArming, TorpedoControllerMarker,
+    TorpedoSectionConfig, TorpedoTargetChosen, TorpedoTargetEntity, TorpedoTargetPosition,
+    TorpedoWeave,
 };
 use nova_world::{
     prelude::{CurrentSector, FrozenBodyType, FrozenSectors, SectorCoord, WorldConfig},
@@ -1065,6 +1067,88 @@ fn a_save_waits_out_a_live_blast_and_a_raking_slug() {
     assert_eq!(round.flight.rake_radius, Some(0.5));
 }
 
+/// A section throws a real kinematic damage spark; the save must accept
+/// that visual effect without trying to record it.
+#[test]
+fn a_damage_spark_does_not_fail_the_save() {
+    let root = tempfile::tempdir().unwrap();
+    let mut app = App::new();
+    app.add_plugins(crate::test_support::WorldSaveTestPlugin);
+    app.insert_resource(Assets::::default());
+    app.insert_resource(Assets::::default());
+    app.add_plugins(DamageSparksPlugin);
+    let mut world = std::mem::take(app.world_mut());
+    crate::test_support::arm_save_fixture(&mut world);
+    let (folder, lock) = create_world(root.path(), "Sparking").unwrap();
+    world.insert_resource(WorldSaveSession::created(
+        folder,
+        lock,
+        "Sparking".to_string(),
+        42,
+    ));
+    world.spawn((
+        DamageSparks::default(),
+        DamageLevel(0.9),
+        GlobalTransform::IDENTITY,
+    ));
+
+    frame(&mut world);
+    assert!(
+        world
+            .query_filtered::<(), (With, With)>()
+            .iter(&world)
+            .count()
+            > 0,
+        "the failing section actually threw a damage spark"
+    );
+
+    world.resource_mut::().request_leave();
+    run_until_idle(&mut world);
+    assert_eq!(
+        world.resource::().status(),
+        &WorldSaveStatus::Saved { generation: 1 },
+        "a live damage spark must not fail the save"
+    );
+    let digest = world.resource::().digest.0;
+    world.remove_resource::();
+    let (_, _, _, saved) = open_world(root.path(), "sparking", &catalog(digest), &items()).unwrap();
+    assert!(
+        saved.transients.is_empty(),
+        "a visual spark has no save record"
+    );
+}
+
+/// A real impact burst throws kinematic sparks; the freeze skips their
+/// physics bodies without making a saved record.
+#[test]
+fn live_impact_sparks_have_no_frozen_records() {
+    let mut app = App::new();
+    app.insert_resource(Assets::::default());
+    app.insert_resource(Assets::::default());
+    app.insert_resource(CurrentSector(SectorCoord::ORIGIN));
+    app.add_plugins(ImpactSparkPlugin);
+    app.world_mut().trigger(ImpactSparks {
+        at: Vec3::ZERO,
+        count: 2,
+        force: 1.0,
+    });
+    app.update();
+    let mut sparks = app
+        .world_mut()
+        .query_filtered::<(), With>();
+    assert_eq!(
+        sparks.iter(app.world()).count(),
+        2,
+        "the burst spawned sparks"
+    );
+    assert!(
+        super::transients::freeze_transients(app.world_mut())
+            .unwrap()
+            .is_empty(),
+        "visual sparks leave no save records"
+    );
+}
+
 /// Set the bodies `slug`'s rake is armed against, through its reflection.
 fn set_rake_armed(world: &mut World, slug: Entity, armed: Vec<(Entity, f32)>) {
     let mut rake = world.get_mut::(slug).unwrap();
diff --git a/crates/nova_world_base/src/save/transients.rs b/crates/nova_world_base/src/save/transients.rs
index f58d3ee67..f76c0780b 100644
--- a/crates/nova_world_base/src/save/transients.rs
+++ b/crates/nova_world_base/src/save/transients.rs
@@ -4,8 +4,8 @@
 //! A save freezes every live top-level [`TempEntity`] body by its kind. A
 //! body in a multi-frame process makes the save wait, and a body this module
 //! has no record for fails the save, so no transient is dropped without a
-//! decision. A body with no physics, such as a spark or a light, is visual
-//! only and is not saved.
+//! decision. Known visual effects, including sparks with kinematic bodies,
+//! are not saved.
 //!
 //! A Load keeps the records in [`ResumedTransients`] and holds the clocks
 //! ([`FreezeOwner::WorldResume`]) until every desired sector is live and every
@@ -27,18 +27,19 @@ use bevy::{
 use nova_events::prelude::EntityId;
 use nova_gameplay::prelude::{
     resumed_lifetime, CargoCanisterRuntimeId, CarvedChunkMarker, Clocks, DetachedPieceMarker,
-    FreezeOwner, NovaBlast, RailgunSlugProjectileMarker, RoundVelocity, SavedBodyRef,
-    SavedLifetime, SavedOwner, SavedSectionRef, SavedTargetRef, SpaceshipRootMarker, TempEntity,
-    TorpedoProjectileMarker, TransientFreezeFault, TurretBulletProjectileMarker, UnsettledBody,
+    FreezeOwner, ImpactSparkMarker, NovaBlast, RailgunSlugProjectileMarker, RoundVelocity,
+    SavedBodyRef, SavedLifetime, SavedOwner, SavedSectionRef, SavedTargetRef, SpaceshipRootMarker,
+    TempEntity, TorpedoProjectileMarker, TransientFreezeFault, TurretBulletProjectileMarker,
+    UnsettledBody,
 };
 use nova_scenario::prelude::{
     freeze_rock_chunk, thaw_rock_chunk, AsteroidSurfaceMaterial, FrozenRockChunk,
 };
 use nova_ship::prelude::{
     freeze_detached_piece, freeze_round, freeze_shed_fixture, freeze_torpedo, thaw_detached_piece,
-    thaw_round, thaw_shed_fixture, thaw_torpedo, FrozenDetachedPiece, FrozenRound,
-    FrozenShedFixture, FrozenTorpedo, PlaceholderArt, SavedTorpedoTarget, ShedFixtureMarker,
-    SkinAssets, TorpedoTargetEntity,
+    thaw_round, thaw_shed_fixture, thaw_torpedo, DamageSparkMarker, FrozenDetachedPiece,
+    FrozenRound, FrozenShedFixture, FrozenTorpedo, PlaceholderArt, SavedTorpedoTarget,
+    ShedFixtureMarker, SkinAssets, TorpedoTargetEntity,
 };
 use nova_world::prelude::{
     desired_sectors, live_sectors, CurrentSector, SectorCoord, SectorRoot, SectorSnapshotError,
@@ -322,6 +323,8 @@ struct TransientKindQuery {
     shed: Has,
     chunk: Has,
     piece: Has,
+    damage_spark: Has,
+    impact_spark: Has,
     swept: Has,
     body: Has,
 }
@@ -340,6 +343,8 @@ impl TransientKindQueryItem<'_, '_> {
             TransientKind::RockChunk
         } else if self.piece {
             TransientKind::DetachedPiece
+        } else if self.damage_spark || self.impact_spark {
+            TransientKind::Visual
         } else if self.swept || self.body {
             TransientKind::Unknown
         } else {
diff --git a/examples/systems/system_world_resume.rs b/examples/systems/system_world_resume.rs
index afd5c922d..8f57c0e9a 100644
--- a/examples/systems/system_world_resume.rs
+++ b/examples/systems/system_world_resume.rs
@@ -52,7 +52,7 @@
 //! which waits for ITS OWN leave save before the process exits.
 //!
 //! ONE SUBJECT: a save written by one process opens correctly in another.
-//! The save and load screens, the leave overlay and the status line are
+//! The save and load screens, the leave overlay and the save notice are
 //! `nova_menu`'s own range; the save format and the writer are
 //! `nova_world_base`'s. This range is the player-visible round trip only.
 //!
@@ -170,8 +170,6 @@ const LEAVE_TRY_AGAIN_BUTTON: &str = "Leave Try Again Button";
 const LOAD_SCREEN: &str = "Scenario Loading Screen";
 #[cfg(feature = "debug")]
 const LEAVE_OVERLAY: &str = "Leave Overlay";
-#[cfg(feature = "debug")]
-const SAVE_STATUS_LINE: &str = "Save Status Line";
 
 /// Seconds a load or a stream gets on a software-rendered CI GPU.
 #[cfg(feature = "debug")]
@@ -1045,18 +1043,15 @@ fn create_script(
         .until(saved_past_first_write())
         .deadline(CROSSING_DEADLINE_SECS)
         .add()
-        // The crossing streams a full window under the Loading screen; the
-        // status line is only on screen once that comes down.
+        // The crossing streams a full window under the Loading screen. Wait
+        // for that stable screen to clear; the save notice is transient.
         .step("world_resume: wait for the Loading screen to come down")
-        .until(nova_autopilot::predicate::and(
-            nova_autopilot::predicate::not(ui_node_present(LOAD_SCREEN)),
-            ui_node_present(SAVE_STATUS_LINE),
-        ))
+        .until(nova_autopilot::predicate::not(ui_node_present(LOAD_SCREEN)))
         .deadline(SESSION_SECS)
         .add()
-        .step("world_resume: shoot the save status line")
-        .on_enter(|world: &mut World| shoot(world, "world_resume-status-line.png"))
-        .until(shot_written("world_resume-status-line.png"))
+        .step("world_resume: capture after crossing")
+        .on_enter(|world: &mut World| shoot(world, "world_resume-after-crossing.png"))
+        .until(shot_written("world_resume-after-crossing.png"))
         .deadline(BEAT_DEADLINE_SECS)
         .add()
         .step("world_resume: assert the saved header on disk")
diff --git a/web/src/wiki/getting-started.md b/web/src/wiki/getting-started.md
index c77d0d71c..a20d5f33e 100644
--- a/web/src/wiki/getting-started.md
+++ b/web/src/wiki/getting-started.md
@@ -42,8 +42,8 @@ In any scenario, Esc pauses the game and gives you Resume / Retry (re
      `list_worlds`, `delete_world`, `world_slug`, `WorldRefusal`), save/session.rs
      (`WorldSaveSession`, `SaveReason`, death at :316-347), save/transients.rs
      (`FrozenTransient`, `WORLD_RESUME_SECONDS_MAX` 240 s); crates/nova_menu/src/
-     world_setup.rs (Create), load_screen.rs (Load), save_status.rs (status
-     line), leave.rs (leave overlay, Retry), outcome.rs (Defeat Load last
+     world_setup.rs (Create), load_screen.rs (Load), save_status.rs (save
+     notice), leave.rs (leave overlay, Retry), outcome.rs (Defeat Load last
      save); crates/nova_authoring/src/base_content/scenarios/open_world.rs
      (the death Defeat); crates/nova_assets/src/storage.rs
      `worlds_root`; crates/nova_core/src/loading_screen.rs (resume progress
@@ -55,7 +55,7 @@ Each world is a folder named after it, under this system's data folder, inside `
 
 The name field opens on `New World`; keep it or type your own. **Create** takes a name of 1 to 32 letters, digits, spaces, `_` or `-`, and the seed field above it. A name already taken, or one this system cannot make a folder for, is refused under the field and nothing starts. The game takes the world's first save as soon as the world comes up.
 
-The game saves when you cross into a new sector, and again when you leave: **Back to Main Menu**, **Exit** and the window's close button each wait on that save before they act. A status line at the top right reads `World saved`, `Saving world...`, `Waiting to save: ` or `SAVE FAILED: ` for the open world's last attempt. Leaving shows an overlay while its save runs; a failed leave save offers **Try again** or **Leave without saving**, which keeps the last good save and leaves anyway.
+The game saves when you cross into a new sector, and again when you leave: **Back to Main Menu**, **Exit** and the window's close button each wait on that save before they act. While a save is writing or waiting, a notice at the bottom right says `Saving world...`. A successful save shows `World saved` for three seconds; a failure shows `SAVE FAILED: ` until the next attempt or you leave. The grave/tilde HUD key (~) hides these notices with the rest of the HUD. An existing save is silent when first opened. Leaving shows an overlay while its save runs; a failed leave save offers **Try again** or **Leave without saving**, which keeps the last good save and leaves anyway.
 
 Dying ends the run with a **DEFEAT** panel: "Your ship was destroyed." On the desktop build it offers **Load last save** and **Main Menu**, and Enter goes to the menu; the web build offers **Main Menu** alone. Dying writes nothing - the last good save stays on disk - and a way out after a death writes nothing either. The pause menu's Retry reads **Load last save** instead of restarting too: it reopens the world from disk rather than the seed.
 

From 553eb60a8b7e4305175f5de55b440287bc5cbe0a Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 15:18:56 +0300
Subject: [PATCH 19/22] Box prepared frozen body payloads

---
 crates/nova_world/src/frozen.rs | 22 ++++++++--------------
 1 file changed, 8 insertions(+), 14 deletions(-)

diff --git a/crates/nova_world/src/frozen.rs b/crates/nova_world/src/frozen.rs
index f81c4e352..4ed351517 100644
--- a/crates/nova_world/src/frozen.rs
+++ b/crates/nova_world/src/frozen.rs
@@ -302,12 +302,9 @@ impl FrozenSector {
 #[derive(Debug)]
 pub(crate) enum PreparedFrozenBody {
     /// A body that thaws where it froze.
-    Body {
-        body: FrozenBody,
-        geometry: Option,
-    },
+    Body(Box<(FrozenBody, Option)>),
     /// A ship still held back, to spawn once the observer is clear of it.
-    HeldShip(SectorShip),
+    HeldShip(Box),
 }
 
 impl PreparedFrozenBody {
@@ -328,16 +325,13 @@ impl PreparedFrozenBody {
                          of that id"
                     );
                 };
-                Self::HeldShip(ship.clone())
+                Self::HeldShip(Box::new(ship.clone()))
             }
             FrozenBodyType::Asteroid(rock) => {
-                let geometry = Some(prepare_frozen_asteroid(rock));
-                Self::Body { body, geometry }
+                let geometry = prepare_frozen_asteroid(rock);
+                Self::Body(Box::new((body, Some(geometry))))
             }
-            _ => Self::Body {
-                body,
-                geometry: None,
-            },
+            _ => Self::Body(Box::new((body, None))),
         }
     }
 }
@@ -542,10 +536,10 @@ pub(crate) fn thaw_record(commands: &mut Commands, root: Entity, bodies: Vec
 {
-                commands.spawn((PendingSectorShip(ship), ChildOf(root)));
+                commands.spawn((PendingSectorShip(*ship), ChildOf(root)));
                 continue;
             }
-            PreparedFrozenBody::Body { body, geometry } => (body, geometry),
+            PreparedFrozenBody::Body(prepared) => *prepared,
         };
         let mut entity = commands.spawn((
             ScenarioScopedMarker,

From f51d4ae75786841f5d88e10fc0ef26e852ef3355 Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 15:40:51 +0300
Subject: [PATCH 20/22] Adapt world sector example to catalog IDs

---
 examples/systems/system_world_sectors.rs | 30 +++++++++++++++++-------
 1 file changed, 21 insertions(+), 9 deletions(-)

diff --git a/examples/systems/system_world_sectors.rs b/examples/systems/system_world_sectors.rs
index c8c3e1302..2969ee465 100644
--- a/examples/systems/system_world_sectors.rs
+++ b/examples/systems/system_world_sectors.rs
@@ -580,7 +580,7 @@ fn held_objects(world: &mut World) -> BTreeMap {
             let id = if let Some(id) = world.get::(*child) {
                 id.0.clone()
             } else if let Some(pending) = world.get::(*child) {
-                pending.ship().id.clone()
+                pending.ship().id.as_str().to_owned()
             } else {
                 continue;
             };
@@ -600,7 +600,12 @@ fn named_objects(
 ) -> BTreeSet {
     cells
         .into_iter()
-        .flat_map(|coord| describe(coord, config).object_ids())
+        .flat_map(|coord| {
+            describe(coord, config)
+                .object_ids()
+                .into_iter()
+                .map(|id| id.as_str().to_owned())
+        })
         .collect()
 }
 
@@ -627,7 +632,7 @@ fn exhaust_retiring_rock(world: &mut World) {
             describe(*coord, &config)
                 .asteroids()
                 .iter()
-                .map(|rock| rock.id.clone())
+                .map(|rock| rock.id.as_str().to_owned())
                 .collect::>()
         })
         .collect();
@@ -1036,7 +1041,7 @@ fn report_clearance(world: &mut World) {
             .iter()
             .map(|body| {
                 (
-                    body.id.clone(),
+                    body.id.as_str().to_owned(),
                     body.position,
                     // A rock's meshed surface reaches up to this multiple past
                     // its nominal radius, so that is what has to clear.
@@ -1045,7 +1050,7 @@ fn report_clearance(world: &mut World) {
             })
             .chain(description.planets().iter().map(|planet| {
                 (
-                    planet.id.clone(),
+                    planet.id.as_str().to_owned(),
                     planet.position,
                     planet.config.body_radius(),
                 )
@@ -1054,7 +1059,7 @@ fn report_clearance(world: &mut World) {
                 description
                     .ships()
                     .iter()
-                    .map(|ship| (ship.id.clone(), ship.position, ship.clearance)),
+                    .map(|ship| (ship.id.as_str().to_owned(), ship.position, ship.clearance)),
             )
             .collect();
         objects += placed.len();
@@ -1145,7 +1150,12 @@ fn report_initial_set(world: &mut World) {
     // stands in. Counted, not asserted: how far bodies drift is physics.
     let adopted: Vec<&String> = held
         .iter()
-        .filter(|(id, (coord, _))| !describe(*coord, &config).object_ids().contains(id))
+        .filter(|(id, (coord, _))| {
+            !describe(*coord, &config)
+                .object_ids()
+                .iter()
+                .any(|object_id| object_id.as_str() == id.as_str())
+        })
         .map(|(id, _)| id)
         .collect();
     let objects = held.len();
@@ -1272,7 +1282,7 @@ fn report_places(world: &mut World) {
         }
 
         for planet in description.planets() {
-            let entity = *by_id.get(&planet.id).unwrap_or_else(|| {
+            let entity = *by_id.get(planet.id.as_str()).unwrap_or_else(|| {
                 panic!(
                     "world sectors: {coord} describes planetoid '{}' but spawned none",
                     planet.id
@@ -1308,7 +1318,7 @@ fn report_places(world: &mut World) {
         }
 
         for ship in description.ships() {
-            let entity = *by_id.get(&ship.id).unwrap_or_else(|| {
+            let entity = *by_id.get(ship.id.as_str()).unwrap_or_else(|| {
                 panic!(
                     "world sectors: {coord} describes derelict '{}' but spawned none",
                     ship.id
@@ -1614,10 +1624,12 @@ fn report_world_replacement(world: &mut World) {
         let old_ids: BTreeSet = describe(cell, &old_config)
             .object_ids()
             .into_iter()
+            .map(|id| id.as_str().to_owned())
             .collect();
         let new_ids: BTreeSet = describe(cell, &new_config)
             .object_ids()
             .into_iter()
+            .map(|id| id.as_str().to_owned())
             .collect();
         assert!(
             !old_ids.is_empty() && !new_ids.is_empty(),

From d9728ab2874856873fde7b91aa96a9de9a11be0b Mon Sep 17 00:00:00 2001
From: Alex Jercan 
Date: Sun, 11 Oct 2026 16:01:38 +0300
Subject: [PATCH 21/22] Update examples for item catalog IDs

---
 examples/playable/mine_and_sell.rs             | 2 +-
 examples/playable/world_clusters.rs            | 2 +-
 examples/playable/world_features.rs            | 4 ++--
 examples/screenshots/lesson_cargo.rs           | 4 ++--
 examples/screenshots/loop_world_start.rs       | 2 +-
 examples/screenshots/screenshot_mining_beam.rs | 2 +-
 6 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/examples/playable/mine_and_sell.rs b/examples/playable/mine_and_sell.rs
index 4af01b272..3de72d1d3 100644
--- a/examples/playable/mine_and_sell.rs
+++ b/examples/playable/mine_and_sell.rs
@@ -246,7 +246,7 @@ mod walk {
     const ROCK_NEAR_FACE: Vec3 = Vec3::new(1.0, 1.0, -13.3);
 
     /// The ore the rock's kind yields.
-    const ORE: &str = ItemRoleType::StoneOre;
+    const ORE: &str = ItemRoleType::StoneOre.id();
 
     /// Real-seconds backstop for a state wait, long enough for lavapipe
     /// frames.
diff --git a/examples/playable/world_clusters.rs b/examples/playable/world_clusters.rs
index 895ef1a73..56df9e873 100644
--- a/examples/playable/world_clusters.rs
+++ b/examples/playable/world_clusters.rs
@@ -1090,7 +1090,7 @@ fn check_seam_groups(world: &mut World) {
             owners.entry(body.source).or_default().push(plan.coord);
             if body.outcome == Outcome::Placed {
                 assert!(
-                    live.contains(&body.id),
+                    live.contains(body.id.as_str()),
                     "world clusters: planned body {} is not live in {}",
                     body.id,
                     plan.coord
diff --git a/examples/playable/world_features.rs b/examples/playable/world_features.rs
index e0d52ba15..581a5f06f 100644
--- a/examples/playable/world_features.rs
+++ b/examples/playable/world_features.rs
@@ -863,12 +863,12 @@ fn search_ship_shots(world: &mut World) {
                             .distance(home)
                             .get()
                             .total_cmp(&best.position.distance(home).get())
-                            .then_with(|| ship.id.cmp(&best.id))
+                            .then_with(|| ship.id.as_str().cmp(best.id.as_str()))
                             .is_lt()
                     });
                     if nearer {
                         shots[r][c] = Some(ShipShot {
-                            id: ship.id,
+                            id: ship.id.as_str().to_owned(),
                             cell,
                             civilization: ship.civilization,
                             role: ship.role,
diff --git a/examples/screenshots/lesson_cargo.rs b/examples/screenshots/lesson_cargo.rs
index a43cca0b2..646b5697e 100644
--- a/examples/screenshots/lesson_cargo.rs
+++ b/examples/screenshots/lesson_cargo.rs
@@ -524,7 +524,7 @@ mod walk {
         info!("lesson_cargo: the intake queues {queued:?}");
         assert_eq!(
             queued,
-            vec![vec![(ItemRoleType::HullPlate, DERELICT_PLATES)]],
+            vec![vec![(ItemRoleType::HullPlate.id(), DERELICT_PLATES)]],
             "the Jettison queued one canister of every plate"
         );
     }
@@ -549,7 +549,7 @@ mod walk {
         assert_eq!(world.resource::().ejected, 1);
         assert_eq!(
             live,
-            vec![vec![(ItemRoleType::HullPlate, DERELICT_PLATES)]],
+            vec![vec![(ItemRoleType::HullPlate.id(), DERELICT_PLATES)]],
             "the intake dropped one canister of every plate"
         );
     }
diff --git a/examples/screenshots/loop_world_start.rs b/examples/screenshots/loop_world_start.rs
index 636fcd123..0fcdb64ef 100644
--- a/examples/screenshots/loop_world_start.rs
+++ b/examples/screenshots/loop_world_start.rs
@@ -461,7 +461,7 @@ fn predict_arrival_body(
         asteroids
             .chain(planets)
             .max_by(|a, b| a.2.get().total_cmp(&b.2.get()))
-            .map(|(id, position, radius)| (coord, id, position, radius))
+            .map(|(id, position, radius)| (coord, id.as_str().to_owned(), position, radius))
     })
 }
 
diff --git a/examples/screenshots/screenshot_mining_beam.rs b/examples/screenshots/screenshot_mining_beam.rs
index 4803e9182..c5ed74ba2 100644
--- a/examples/screenshots/screenshot_mining_beam.rs
+++ b/examples/screenshots/screenshot_mining_beam.rs
@@ -773,7 +773,7 @@ fn mining_script() -> nova_protocol::nova_debug::harness::AutopilotPlugin
Date: Sun, 11 Oct 2026 16:37:04 +0300
Subject: [PATCH 22/22] Install spaceship observer in provocation test rig

---
 crates/nova_assets/tests/scenario_provocation.rs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/crates/nova_assets/tests/scenario_provocation.rs b/crates/nova_assets/tests/scenario_provocation.rs
index f876eafee..d4eaf397b 100644
--- a/crates/nova_assets/tests/scenario_provocation.rs
+++ b/crates/nova_assets/tests/scenario_provocation.rs
@@ -252,6 +252,7 @@ fn slice_app() -> App {
     app.add_plugins(bevy::asset::AssetPlugin::default());
     app.init_asset::();
     app.add_plugins(GameEventsPlugin::::default());
+    app.add_plugins(SpaceshipPlugin);
     app.init_resource::();
     app.init_resource::();
     app.init_resource::();