feat(@projects/@magic-civilization): add game-pack subscription manifest task

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-29 23:37:52 -04:00
parent bdc82daeb8
commit e02cb440fc
5 changed files with 168 additions and 8 deletions

View file

@ -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** |
</td><td valign='top' style='padding-left:2em'>
@ -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/<category>/ 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 |

View file

@ -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/<category>/`. 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/<category>/` — 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/<category>/` (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/<theme_id>/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/<category>/ 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/<category>/`. 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.

View file

@ -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/<category>/`, with `public/games/<game>/` 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/<category>/`. 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/<category>/` — 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/<category>/` (otherwise Game 2 races and units would automatically appear in Age of Dwarves saves)."
},
{
"id": "p2-06",
"title": "Export pipeline for Windows / macOS / Linux",

View file

@ -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}"
);
}
}
}
}

View file

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