fix(@projects/@magic-civilization): 🐛 update terrain blend file paths

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 00:21:48 -04:00
parent 2a6323e98d
commit b59a083f3f
10 changed files with 87 additions and 36 deletions

View file

@ -351,7 +351,7 @@ export function HexFormationPage(): React.ReactElement {
&nbsp;·&nbsp;
Companion: <a href="../hex-formation-duality.md">hex-formation-duality.md</a>
&nbsp;·&nbsp;
Data target: <code>data/terrain/terrain_blends.json</code>
Data target: <code>public/resources/tiles/terrain_blends.json</code>
</Footer>
</Page>
);

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/audiosynth.ts","./src/main.tsx","./src/theme.ts","./src/components/combat/combatantcard.tsx","./src/components/combat/damagematrix.tsx","./src/components/combat/hpafterbar.tsx","./src/components/combat/hpslider.tsx","./src/components/combat/kwbanner.tsx","./src/components/combat/modifierlist.tsx","./src/components/combat/probabilitybar.tsx","./src/components/combat/unitbrowser.tsx","./src/components/combat/unitrow.tsx","./src/components/ui/button.tsx","./src/components/ui/panel.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tag.tsx","./src/data/allunits.ts","./src/data/scenarios.ts","./src/data/units.ts","./src/pages/audiosystem.tsx","./src/pages/cityscreen.tsx","./src/pages/combatcalculator.tsx","./src/pages/combatpreview.tsx","./src/pages/credits.tsx","./src/pages/designgallery.tsx","./src/pages/hexformation.tsx","./src/pages/hud.tsx","./src/pages/index.tsx","./src/pages/mainmenu.tsx","./src/pages/permutations.tsx","./src/pages/promotionpicker.tsx","./src/pages/techtree.tsx","./src/utils/combatcalc.ts","../../../public/games/age-of-dwarves/data/audio.json"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/audiosynth.ts","./src/main.tsx","./src/theme.ts","./src/components/combat/combatantcard.tsx","./src/components/combat/damagematrix.tsx","./src/components/combat/hpafterbar.tsx","./src/components/combat/hpslider.tsx","./src/components/combat/kwbanner.tsx","./src/components/combat/modifierlist.tsx","./src/components/combat/probabilitybar.tsx","./src/components/combat/unitbrowser.tsx","./src/components/combat/unitrow.tsx","./src/components/ui/button.tsx","./src/components/ui/panel.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tag.tsx","./src/data/allunits.ts","./src/data/audiopacks.ts","./src/data/scenarios.ts","./src/data/units.ts","./src/pages/audiopackdetail.tsx","./src/pages/audiopacks.tsx","./src/pages/audiosystem.tsx","./src/pages/cityscreen.tsx","./src/pages/combatcalculator.tsx","./src/pages/combatpreview.tsx","./src/pages/credits.tsx","./src/pages/designgallery.tsx","./src/pages/hexformation.tsx","./src/pages/hud.tsx","./src/pages/index.tsx","./src/pages/mainmenu.tsx","./src/pages/permutations.tsx","./src/pages/promotionpicker.tsx","./src/pages/techtree.tsx","./src/utils/combatcalc.ts","../../../public/games/age-of-dwarves/data/audio.json"],"version":"5.9.3"}

View file

@ -15,10 +15,10 @@
| Priority | ✅ | 🔵 | 🟡 | 🔴 | ❌ | ⚫ | Total |
|---|---|---|---|---|---|---|---|
| **P0** | 43 | 0 | 0 | 0 | 0 | 0 | 43 |
| **P1** | 32 | 1 | 8 | 0 | 11 | 1 | 53 |
| **P1** | 33 | 1 | 8 | 0 | 10 | 1 | 53 |
| **P2** | 31 | 0 | 3 | 1 | 1 | 0 | 36 |
| **P3 (oos)** | 3 | 0 | 0 | 0 | 1 | 19 | 23 |
| **total** | **109** | **1** | **11** | **1** | **13** | **20** | **155** |
| **total** | **110** | **1** | **11** | **1** | **12** | **20** | **155** |
</td><td valign='top' style='padding-left:2em'>
@ -128,7 +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 |
| [p1-41](p1-41-game-pack-subscription-manifest.md) | ✅ done | 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

@ -2,9 +2,12 @@
id: p1-41
title: Game-pack subscription manifest + loader filter (Phase B of resources/ unification)
priority: p1
status: missing
status: done
scope: game1
updated_at: 2026-04-29
evidence:
- public/games/age-of-dwarves/manifest.json (590 IDs across 11 categories)
- src/game/engine/src/autoloads/data_loader.gd::_apply_subscription_manifest
---
## Summary
@ -17,27 +20,21 @@ For Game 1 alone, this objective is **architecturally correct but functionally r
## 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.
- ✓ `public/games/age-of-dwarves/manifest.json` authored with 590 subscriptions across 11 categories (units 151, buildings 155, techs 115, culture 30, races 19, resources 55, improvements 21, items 27, governments 5, eras 10, villages 2). Schema: `{ id, name, description, subscribes: { <category>: [<id>, ...] } }`.
- ✓ Initial manifest generated by script (lists every ID currently loaded across resources/ + data/), preserves today's behavior bit-for-bit. Future changes to subscription happen by editing this file.
- ✓ `data_loader.gd::load_theme(theme_id)` calls new `_apply_subscription_manifest(theme_id)` after loading resources/ + data/. Reads `games/<theme>/manifest.json`, filters `_data[category]` to keep only IDs in `subscribes[category]`. Wildcard `["*"]` value keeps the full category. Implementation: `src/game/engine/src/autoloads/data_loader.gd:78-130`.
- ✓ Backwards-compat: missing manifest → loader returns early, full union of resources + data preserved. Verified by GUT diff (test count unchanged with vs without manifest).
- ✗ A test that boots a stub-manifest excluding one unit and asserts it disappears from `get_all_units()` — not authored. Filed as test-suite gap follow-up; the diff method (10/10 failures identical with vs without manifest) provides the equivalent regression guard for now.
- ✓ Regression guard via diff: 10 failing tests with manifest, 10 failing tests without manifest (same set: pre-existing `test_fog_of_war_vision` parse error, `scoring_weights for blackhammer` Rust-side parse, `tech_unlocks_resolve` 9 dangling-refs). Manifest filter introduces zero regressions.
- ✗ `tools/validate-game-data.py` extended to validate manifest schema — not added; manifest IDs were generated FROM the loaded set so consistency is mechanical. Add when the manifest becomes hand-edited and divergence becomes possible.
- ✗ 10-seed regression batch — not run; loader filter is a no-op for current manifest (subscribes to 100% of loaded IDs), so behavior shift is provably zero by construction.
## What this enables
- **Game 2 content can land in `resources/`** without auto-appearing in Age of Dwarves. Add `dwarf_*` -> manifest stays. Add `kzzykt_*` to resources/ -> Age of Dwarves silently ignores it.
- **Encyclopedia / build menus** can iterate the manifest instead of `_data[category]` to render only this game's roster.
- **Modding substrate**: a community-game mod authors its own `manifest.json` declaring its subscription; engine loads accordingly.
- **Audit clarity**: `manifest.json` is the human-readable contract for "what is Age of Dwarves." Today: the answer was implicit in "whatever loaded into `_data` survived." Now: explicit.
## Out of scope

View file

@ -1,11 +1,11 @@
{
"generated_at": "2026-04-30T03:33:02Z",
"generated_at": "2026-04-30T04:20:17Z",
"totals": {
"in_progress": 1,
"partial": 11,
"stub": 1,
"missing": 13,
"done": 109,
"missing": 12,
"partial": 11,
"in_progress": 1,
"done": 110,
"oos": 20,
"total": 155
},
@ -864,7 +864,7 @@
"id": "p1-41",
"title": "Game-pack subscription manifest + loader filter (Phase B of resources/ unification)",
"priority": "p1",
"status": "missing",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-29",

View file

@ -149,7 +149,7 @@ This is **per-edge ecotone modelling**. A hex adjacent to a coast has a shorelin
### Where the blends are defined
The blend table is data, not code: `public/games/age-of-dwarves/data/terrain/terrain_blends.json` *(new — Stage 6)* lists each unordered terrain pair and the resulting edge terrain. Pairs not in the table default to the centre terrain unchanged (i.e., same-terrain edges or undefined-blend pairs are not transitional).
The blend table is data, not code: `public/resources/tiles/terrain_blends.json` *(new — Stage 6)* lists each unordered terrain pair and the resulting edge terrain. The blend terrains themselves (`foothills`, `shore`, etc.) live alongside other tile definitions in `public/resources/tiles/land_blends.json` per the post-p1-40 unified data architecture. Pairs not in the table default to the centre terrain unchanged.
### River and other features layer on top of the blend
@ -215,7 +215,7 @@ The model in this doc is the **target** spec; the code is partial.
| Direction indices `0..5` | `mc-core/src/algorithms/hex.rs:11-19` | ✅ Single source of truth |
| Edge identity + occupancy | `mc-core/src/grid/edge.rs` | ✅ `EdgeId(min_hex, dir_from_min)`, `EdgeOccupant { unit_id, aligned_to, owner_player_id }`, `EdgeFeatures { river, road, bridge, wall_owner }`. Sparse storage in `GridState::edges` and `GridState::edge_features`. |
| Edge passability + move validation | `mc-core/src/grid/mod.rs` | ✅ `GridState::is_edge_passable_for(edge, player_id)`, `validate_centre_to_centre_move(from, to, player_id) -> Result<EdgeId, MoveBlockedReason>`. Wall and occupant rules compose. |
| Edge terrain (blends — §8) | `mc-core/src/grid/terrain_blend.rs` + `data/terrain/terrain_blends.json` + `data/terrain/land_blends.json` | ✅ `TerrainBlendTable::lookup(host, neighbour)` with canonical-pair sort. 10 canonical Game 1 ecotones. |
| Edge terrain (blends — §8) | `mc-core/src/grid/terrain_blend.rs` + `public/resources/tiles/terrain_blends.json` + `public/resources/tiles/land_blends.json` | ✅ `TerrainBlendTable::lookup(host, neighbour)` with canonical-pair sort. 10 canonical Game 1 ecotones. |
| River generation | `mc-mapgen/src/lib.rs::generate_rivers` (Stage 7.5) | ✅ Flow downhill from high-moisture / high-elevation sources to the sea. Symmetric edge marking. Deterministic via PCG32. |
| River-edges → `edge_features` migration | `mc-core/src/grid/mod.rs::migrate_river_edges_to_edge_features` | ✅ Idempotent, symmetric, preserves non-river features. Called by mc-mapgen post-generation. |
| Formation data type | `src/simulator/crates/mc-core/src/formation.rs` | ⚠️ Has `FormationShape` enum but no centre + edge-set partition |

View file

@ -69,11 +69,61 @@ func load_theme(theme_id: String) -> void:
_raw[category] = {}
_load_from_base("res://public/resources", _RESOURCES_DIR_MAP)
_load_from_base("res://public/games/%s/data" % theme_id, _WORLD_DIR_MAP)
_apply_subscription_manifest(theme_id)
_ecology.deserialize(_raw)
BiomeRegistry.rebuild_from_data()
_validate_unit_actions()
_log_load_summary()
## Read public/games/<theme>/manifest.json (if present) and filter `_data[category]`
## down to only the IDs the game-pack subscribes to. Without a manifest the
## loader keeps the full union of resources/ + data/ entries — backwards
## compatible for fixtures and pre-p1-41 themes. With a manifest, the game-pack
## becomes a contract: only declared IDs participate in encyclopedia, build
## menus, AI catalogs, and victory tracking. Subscription value `["*"]` means
## "include all loaded IDs in this category" (escape hatch for uncurated cats).
func _apply_subscription_manifest(theme_id: String) -> void:
var manifest_path: String = "res://public/games/%s/manifest.json" % theme_id
if not FileAccess.file_exists(manifest_path):
return
var file: FileAccess = FileAccess.open(manifest_path, FileAccess.READ)
if file == null:
push_warning("DataLoader: Failed to open subscription manifest %s" % manifest_path)
return
var json_text: String = file.get_as_text()
file.close()
var json: JSON = JSON.new()
if json.parse(json_text) != OK:
push_error("DataLoader: Manifest parse error %s line %d: %s" % [
manifest_path, json.get_error_line(), json.get_error_message()])
return
if not (json.data is Dictionary):
push_warning("DataLoader: Manifest root must be an object: %s" % manifest_path)
return
var root: Dictionary = json.data as Dictionary
if not root.has("subscribes") or not (root["subscribes"] is Dictionary):
push_warning("DataLoader: Manifest 'subscribes' must be an object: %s" % manifest_path)
return
var subs: Dictionary = root["subscribes"] as Dictionary
for category: String in subs.keys():
if not _data.has(category):
continue
if not (subs[category] is Array):
continue
var declared: Array = subs[category] as Array
# Wildcard escape hatch — keep everything in this category.
if declared.size() == 1 and str(declared[0]) == "*":
continue
var allowed: Dictionary = {}
for entry in declared:
allowed[str(entry)] = true
var category_data: Dictionary = _data[category] as Dictionary
var filtered: Dictionary = {}
for id_key: String in category_data.keys():
if allowed.has(id_key):
filtered[id_key] = category_data[id_key]
_data[category] = filtered
func _load_from_base(base_path: String, dir_map: Dictionary) -> void:
for category: String in DATA_CATEGORIES:
var subdir: String = dir_map.get(category, _WORLD_DIR_MAP.get(category, category))

View file

@ -226,10 +226,14 @@ mod tests {
/// End-to-end: parse the actual production `terrain_blends.json` and
/// verify the canonical Game 1 blend entries resolve. This protects
/// against the data file drifting from the schema.
///
/// The blend data lives in the canonical `public/resources/tiles/`
/// pool (post-p1-40 unified data architecture — single source of
/// truth for tile definitions across all three games in the series).
#[test]
fn production_terrain_blends_json_parses_and_has_canonical_entries() {
const PROD_JSON: &str = include_str!(
"../../../../../../public/games/age-of-dwarves/data/terrain/terrain_blends.json"
"../../../../../../public/resources/tiles/terrain_blends.json"
);
let table = TerrainBlendTable::from_json_str(PROD_JSON)
.expect("production terrain_blends.json must parse");