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;
|