From e02cb440fc343c0f296e50f86f4e57a33b61830c Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 29 Apr 2026 23:37:52 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20game-pack=20subscription=20manifest=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/objectives/README.md | 5 +- .../p1-41-game-pack-subscription-manifest.md | 50 ++++++++++ .../games/age-of-dwarves/data/objectives.json | 20 +++- src/simulator/crates/mc-core/src/grid/edge.rs | 98 +++++++++++++++++++ src/simulator/crates/mc-core/src/grid/mod.rs | 3 +- 5 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 .project/objectives/p1-41-game-pack-subscription-manifest.md diff --git a/.project/objectives/README.md b/.project/objectives/README.md index 18677207..acf6354b 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -15,10 +15,10 @@ | Priority | โœ… | ๐Ÿ”ต | ๐ŸŸก | ๐Ÿ”ด | โŒ | โšซ | Total | |---|---|---|---|---|---|---|---| | **P0** | 43 | 0 | 0 | 0 | 0 | 0 | 43 | -| **P1** | 32 | 1 | 8 | 0 | 10 | 1 | 52 | +| **P1** | 32 | 1 | 8 | 0 | 11 | 1 | 53 | | **P2** | 31 | 0 | 3 | 1 | 1 | 0 | 36 | | **P3 (oos)** | 3 | 0 | 0 | 0 | 1 | 19 | 23 | -| **total** | **109** | **1** | **11** | **1** | **12** | **20** | **154** | +| **total** | **109** | **1** | **11** | **1** | **13** | **20** | **155** | @@ -128,6 +128,7 @@ | [p1-38](p1-38-biome-economy-coupling.md) | ๐ŸŸก partial | Biome โ†’ economy coupling โ€” population & luxury driven by live ecology | [shipwright](../team-leads/shipwright.md) | 2026-04-27 | | [p1-39](p1-39.md) | ๐ŸŸก partial | Port per-yield difficulty multipliers from GDScript into Rust crates (Rail-1) โ€” research + culture | [warcouncil](../team-leads/warcouncil.md) | 2026-04-27 | | [p1-40](p1-40-single-source-of-truth-resources.md) | โœ… done | Collapse data// override layer into single source of truth at resources/ | โ€” | 2026-04-29 | +| [p1-41](p1-41-game-pack-subscription-manifest.md) | โŒ missing | Game-pack subscription manifest + loader filter (Phase B of resources/ unification) | โ€” | 2026-04-29 | | [p2-06](p2-06-export-pipeline.md) | โœ… done | Export pipeline for Windows / macOS / Linux | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | | [p2-16](p2-16-audio-assets.md) | ๐Ÿ”ต in_progress | Audio assets โ€” in-theme OSS launch pack + source ledger | [asset-audio](../team-leads/asset-audio.md) | 2026-04-27 | | [p2-22](p2-22-sprite-generation-pipeline.md) | ๐ŸŸก partial | Sprite generation pipeline โ€” runnable end-to-end | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 | diff --git a/.project/objectives/p1-41-game-pack-subscription-manifest.md b/.project/objectives/p1-41-game-pack-subscription-manifest.md new file mode 100644 index 00000000..8d6aaee3 --- /dev/null +++ b/.project/objectives/p1-41-game-pack-subscription-manifest.md @@ -0,0 +1,50 @@ +--- +id: p1-41 +title: Game-pack subscription manifest + loader filter (Phase B of resources/ unification) +priority: p1 +status: missing +scope: game1 +updated_at: 2026-04-29 +--- + +## Summary + +Phase A (`p1-40`) collapsed the data/ override layer into single source of truth at `public/resources//`. All 155 unit IDs and 159 building IDs now live in resources/, one file each. The data loader still walks both layers โ€” `resources/` then `data//` โ€” but the data/ side is now empty for those categories. + +Phase B introduces a per-game **subscription manifest** that declares which resource IDs each game uses, and a loader filter that restricts the in-memory `_data` dict to that subset. This is what makes the architecture viable when a second game exists: Age of Dwarves subscribes only to dwarf-relevant content; Age of Kzzykt (Game 2) subscribes to its own roster without inheriting dwarf-specific entities by accident. + +For Game 1 alone, this objective is **architecturally correct but functionally redundant** โ€” Age of Dwarves currently subscribes to 100% of resources/. The objective is filed at `p1` (not deferred to `p3`) because Game 2 work begins as soon as Game 1 ships EA, and the subscription mechanism needs to exist before content for Game 2 starts landing in `resources//` (otherwise Game 2 races and units would automatically appear in Age of Dwarves saves). + +## Acceptance + +- โœ— `public/games/age-of-dwarves/manifest.json` authored, schema: + ```json + { + "id": "age-of-dwarves", + "subscribes": { + "buildings": ["ale_hall", "barracks", "forge", ...], // every loaded building ID + "units": ["warrior", "worker", "dwarf_warrior", ...], + "techs": ["smelting", "masonry", ...], + "races": ["dwarf"], + "biomes": ["*"], // wildcard for "all" + "improvements": ["*"] + } + } + ``` +- โœ— Initial manifest generated by a script that lists every ID currently loaded โ€” preserves today's behaviour bit-for-bit. +- โœ— `data_loader.gd::load_theme(theme_id)` reads `games//manifest.json` after loading resources/, then filters `_data[category]` to keep only IDs in `subscribes[category]` (or all if the value is `["*"]`). +- โœ— Loader behaviour with no manifest: identical to today (load everything). Backwards-compat for tests / fixtures. +- โœ— A test (GUT or Rust) that boots the engine with a stub manifest excluding a known unit ID and asserts that ID is not in `DataLoader.get_all_units()`. +- โœ— A test that boots Age of Dwarves with the real manifest and asserts the loaded ID set matches today's set bit-for-bit (regression guard against accidental subscription drift). +- โœ— `python3 tools/validate-game-data.py` extended to validate the manifest schema (every subscribed ID must resolve to a real resources// entry). +- โœ— The 10-seed `tools/autoplay-batch.sh 10 300` regression batch shows zero behaviour shift vs pre-manifest baseline. + +## Out of scope + +- Authoring resources/ content for Game 2 (separate Game 2 milestone). +- Building a manifest UI / wizard for game-pack authors (post-EA tooling). +- Mod / community-game support โ€” manifest format is the substrate for that, but mod-loading machinery is its own objective. + +## Blocking trigger + +Land this **before any Game-2-specific entity** lands in `resources//`. Until then it's correct-but-inert. Adding the loader filter without any non-Game-1 content in resources/ has zero observable effect โ€” the test gate is the whole value. diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index 3365377d..52a60543 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -1,13 +1,13 @@ { - "generated_at": "2026-04-29T20:06:03Z", + "generated_at": "2026-04-30T03:33:02Z", "totals": { - "oos": 20, - "done": 109, "in_progress": 1, - "missing": 12, "partial": 11, "stub": 1, - "total": 154 + "missing": 13, + "done": 109, + "oos": 20, + "total": 155 }, "objectives": [ { @@ -860,6 +860,16 @@ "updated_at": "2026-04-29", "summary": "Today `public/games/age-of-dwarves/data/{units,buildings,techs}/` mirrors `public/resources/{units,buildings,techs}/` with override semantics: the loader walks resources/ first, then game/data/ overwrites by id. This created three real bugs in the last few sessions:\n1. Audit blind-spot: 9 \"missing\" buildings and 6 \"missing\" food/processing buildings turned out to live in resources/buildings bundled files that the per-file audit missed.\n2. Silent semantic drift: 14 building IDs are defined in both layers with different cost/tech/effects; the data/ version wins by accident-of-loader-order.\n3. Broken tech gates: 6 of 8 ordinary-building duplicates have `tech_required` in resources/ pointing at non-existent techs (`military_doctrine`, `smelting`, `husbandry`, `scholarship`, `ancestor_rites`, `masonry`, `mathematics`). The data/ overrides are the only thing keeping those buildings buildable.\n\nThe right architecture is one source of truth at `public/resources//`, with `public/games//` carrying only **game-pack-specific configuration** (clan personalities, setup, vocab, difficulty) and a manifest declaring which resource IDs the game subscribes to. No more override layer.\n\nThis objective is the **safe mechanical phase** โ€” move all entity files to resources/ canonical locations and resolve the duplicates. The behavioral phase (subscription manifest + loader filter) splits to `p1-41`." }, + { + "id": "p1-41", + "title": "Game-pack subscription manifest + loader filter (Phase B of resources/ unification)", + "priority": "p1", + "status": "missing", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-29", + "summary": "Phase A (`p1-40`) collapsed the data/ override layer into single source of truth at `public/resources//`. All 155 unit IDs and 159 building IDs now live in resources/, one file each. The data loader still walks both layers โ€” `resources/` then `data//` โ€” but the data/ side is now empty for those categories.\n\nPhase B introduces a per-game **subscription manifest** that declares which resource IDs each game uses, and a loader filter that restricts the in-memory `_data` dict to that subset. This is what makes the architecture viable when a second game exists: Age of Dwarves subscribes only to dwarf-relevant content; Age of Kzzykt (Game 2) subscribes to its own roster without inheriting dwarf-specific entities by accident.\n\nFor Game 1 alone, this objective is **architecturally correct but functionally redundant** โ€” Age of Dwarves currently subscribes to 100% of resources/. The objective is filed at `p1` (not deferred to `p3`) because Game 2 work begins as soon as Game 1 ships EA, and the subscription mechanism needs to exist before content for Game 2 starts landing in `resources//` (otherwise Game 2 races and units would automatically appear in Age of Dwarves saves)." + }, { "id": "p2-06", "title": "Export pipeline for Windows / macOS / Linux", diff --git a/src/simulator/crates/mc-core/src/grid/edge.rs b/src/simulator/crates/mc-core/src/grid/edge.rs index f67e67f0..e67b1577 100644 --- a/src/simulator/crates/mc-core/src/grid/edge.rs +++ b/src/simulator/crates/mc-core/src/grid/edge.rs @@ -121,6 +121,49 @@ pub fn hexes_of_edge(edge: EdgeId) -> [(i32, i32); 2] { [edge.min_hex, (edge.min_hex.0 + dq, edge.min_hex.1 + dr)] } +/// ZOC reach for a unit standing on `edge` per `HEX_GEOMETRY.md` ยง9. +/// +/// An edge unit contests both adjacent centres and the four other edges +/// that share a vertex with it (two on each adjacent hex). Centres come +/// from `hexes_of_edge`; the four vertex-adjacent edges are the +/// neighbouring perimeter edges within each adjacent hex. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ZocReach { + /// The two hex centres directly bordering this edge. + pub centres: [(i32, i32); 2], + /// The four edges that share a vertex with this edge (two on each + /// adjacent hex). Order is stable for testability: + /// `[hex_a_clockwise, hex_a_counterclockwise, hex_b_clockwise, hex_b_counterclockwise]`. + pub adjacent_edges: [EdgeId; 4], +} + +/// Return the 6 edges projected by ZOC from a centre at `hex`. A centre +/// unit projects into all 6 of its host hex's edges per ยง9. +pub fn zoc_from_centre(hex: (i32, i32)) -> [EdgeId; 6] { + edges_of_hex(hex) +} + +/// Return ZOC reach from a unit on `edge`. Both adjacent centres plus the +/// four vertex-adjacent edges per ยง9. +pub fn zoc_from_edge(edge: EdgeId) -> ZocReach { + let [hex_a, hex_b] = hexes_of_edge(edge); + let dir_a = edge.dir_from_min as usize; + let dir_b = reverse_dir(edge.dir_from_min) as usize; + + // Vertex-adjacent edges within a hex are the perimeter neighbours of + // the chosen edge โ€” directions `dir + 1` (counterclockwise) and + // `dir + 5` (clockwise) modulo 6 in hex.rs's direction ordering. + let edge_a_cw = canonical_edge(hex_a, ((dir_a + 5) % 6) as u8); + let edge_a_ccw = canonical_edge(hex_a, ((dir_a + 1) % 6) as u8); + let edge_b_cw = canonical_edge(hex_b, ((dir_b + 5) % 6) as u8); + let edge_b_ccw = canonical_edge(hex_b, ((dir_b + 1) % 6) as u8); + + ZocReach { + centres: [hex_a, hex_b], + adjacent_edges: [edge_a_cw, edge_a_ccw, edge_b_cw, edge_b_ccw], + } +} + #[cfg(test)] mod tests { use super::*; @@ -213,4 +256,59 @@ mod tests { assert!(!f.bridge); assert!(f.wall_owner.is_none()); } + + /// Centre ZOC covers exactly the 6 own edges, no duplicates. + #[test] + fn zoc_from_centre_returns_six_distinct_edges() { + let edges = zoc_from_centre((2, -3)); + for i in 0..6 { + for j in (i + 1)..6 { + assert_ne!(edges[i], edges[j], "duplicate at {i},{j}"); + } + } + } + + /// Edge ZOC: 2 centres + 4 distinct vertex-adjacent edges, none equal to the source edge. + #[test] + fn zoc_from_edge_yields_two_centres_and_four_distinct_adjacent_edges() { + let edge = canonical_edge((0, 0), 0); // E edge from origin + let reach = zoc_from_edge(edge); + + // Both adjacent centres must be the two hexes that share this edge. + let pair = hexes_of_edge(edge); + assert!(reach.centres.contains(&pair[0])); + assert!(reach.centres.contains(&pair[1])); + + // The four adjacent edges must be distinct from each other and from the source. + for i in 0..4 { + assert_ne!( + reach.adjacent_edges[i], edge, + "adjacent_edges[{i}] should not equal the source edge" + ); + for j in (i + 1)..4 { + assert_ne!( + reach.adjacent_edges[i], reach.adjacent_edges[j], + "adjacent_edges[{i}] and [{j}] must be distinct" + ); + } + } + } + + /// `zoc_from_edge` is symmetric: querying via canonical_edge from either side returns the same reach. + #[test] + fn zoc_from_edge_is_symmetric_across_canonical_form() { + for hex in [(0, 0), (3, -2), (-4, 5)] { + for dir in 0u8..6 { + let (dq, dr) = AXIAL_DIRECTIONS[dir as usize]; + let other = (hex.0 + dq, hex.1 + dr); + let from_a = zoc_from_edge(canonical_edge(hex, dir)); + let from_b = zoc_from_edge(canonical_edge(other, reverse_dir(dir))); + // Same canonical edge โ†’ same reach + assert_eq!( + from_a, from_b, + "ZOC reach differs across canonical-edge symmetric inputs at hex={hex:?} dir={dir}" + ); + } + } + } } diff --git a/src/simulator/crates/mc-core/src/grid/mod.rs b/src/simulator/crates/mc-core/src/grid/mod.rs index 61c12628..34585d61 100644 --- a/src/simulator/crates/mc-core/src/grid/mod.rs +++ b/src/simulator/crates/mc-core/src/grid/mod.rs @@ -9,7 +9,8 @@ use std::collections::HashMap; pub mod edge; pub use edge::{ - canonical_edge, edges_of_hex, hexes_of_edge, reverse_dir, EdgeFeatures, EdgeId, EdgeOccupant, + canonical_edge, edges_of_hex, hexes_of_edge, reverse_dir, zoc_from_centre, zoc_from_edge, + EdgeFeatures, EdgeId, EdgeOccupant, ZocReach, }; pub mod terrain_blend;