diff --git a/.project/objectives/p2-36-data-resources-building-duplicates.md b/.project/objectives/p2-36-data-resources-building-duplicates.md index 3538a0a4..02eefd09 100644 --- a/.project/objectives/p2-36-data-resources-building-duplicates.md +++ b/.project/objectives/p2-36-data-resources-building-duplicates.md @@ -2,13 +2,13 @@ id: p2-36 title: Reconcile the 14 building IDs defined in both `resources/buildings/` and `data/buildings/` priority: p2 -status: partial +status: done scope: game1 -updated_at: 2026-04-27 +updated_at: 2026-04-29 evidence: - - public/resources/buildings/ (6 wonder per-file dupes deleted: clan_moot_stone, covenant_stone, grand_observatory, hall_of_ancestors, voice_of_ages, world_pillar) - - public/games/age-of-dwarves/data/buildings/mundane_wonders.json (canonical wonder ladder, unchanged) - - public/games/age-of-dwarves/data/buildings/manifest.json (regenerated: 102 IDs, was 108) + - public/resources/buildings/ (155 unique per-file IDs, zero duplicates with data/) + - public/games/age-of-dwarves/data/buildings/ (deleted entirely as part of p1-40) + - .project/objectives/p1-40-single-source-of-truth-resources.md (the architectural fix that absorbed this objective) --- ## Summary @@ -51,6 +51,10 @@ The pattern is clear: Wonder phase completed autonomously: all 6 wonder duplicates collapsed onto `mundane_wonders.json`. The remaining 8 ordinary-building duplicates each represent a design call (sparse Game-1-friendly override vs richer engine-default with tech gate) and are deferred until that call is made. +## Closure note (2026-04-29) + +Absorbed by `p1-40` (single source of truth at resources/). The 8 remaining ordinary-building duplicates were resolved by moving the data/ definitions into `resources/buildings/.json` (overwriting the broken-tech resources/ versions). The 24 wonders in `mundane_wonders.json` were split into per-file entries during p1-40 as well. `data/buildings/` no longer exists. Zero duplicates remain — verified by post-migration audit. The "design call" framing turned out to be wrong: the resources/ versions had unbuildable tech gates pointing at non-existent techs, so picking data/ wasn't a balance call, it was the only working choice. + ## Out of scope - Authoring new buildings (covered by `p1-32` and other authoring objectives). diff --git a/src/game/engine/tests/unit/test_data_loader_manifest.gd b/src/game/engine/tests/unit/test_data_loader_manifest.gd new file mode 100644 index 00000000..5a596fbe --- /dev/null +++ b/src/game/engine/tests/unit/test_data_loader_manifest.gd @@ -0,0 +1,133 @@ +extends GutTest +## p1-41 regression guard: data_loader's _apply_subscription_manifest must +## restrict _data[category] to manifest-declared IDs, default to "include +## everything" when the manifest is absent, and honor the wildcard escape +## hatch. The Age of Dwarves manifest currently subscribes to the full +## loaded set, so the positive-filter branch is exercised via a stub +## manifest written to a temp theme path. + +const STUB_THEME_ID: String = "_test_manifest_stub" + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func after_all() -> void: + # Clean up the stub theme tree we wrote during tests. + var stub_dir: String = "res://public/games/%s" % STUB_THEME_ID + var dir: DirAccess = DirAccess.open(stub_dir) + if dir != null: + _remove_recursive(dir, stub_dir) + # Leave DataLoader on the real theme so other tests inherit clean state. + DataLoader.load_theme("age-of-dwarves") + + +func _remove_recursive(dir: DirAccess, path: String) -> void: + dir.list_dir_begin() + var name: String = dir.get_next() + while name != "": + var full: String = "%s/%s" % [path, name] + if dir.current_is_dir(): + var sub: DirAccess = DirAccess.open(full) + if sub != null: + _remove_recursive(sub, full) + DirAccess.remove_absolute(full) + else: + DirAccess.remove_absolute(full) + name = dir.get_next() + dir.list_dir_end() + + +# -- Real-manifest contract -- + + +func test_real_manifest_loads_dwarf_founder() -> void: + # Sanity: the real Age of Dwarves manifest subscribes to dwarf_founder. + var u: Dictionary = DataLoader.get_unit("dwarf_founder") + assert_false(u.is_empty(), "dwarf_founder must survive the Age of Dwarves manifest filter") + + +func test_real_manifest_loads_warrior_generic() -> void: + # Generic-class units also live in the manifest after the p1-40 merge. + var u: Dictionary = DataLoader.get_unit("warrior") + assert_false(u.is_empty(), "generic warrior must survive the Age of Dwarves manifest filter") + + +func test_real_manifest_loads_castle_building() -> void: + # Buildings audit: castle was a duplicate-resolved entity, must still load. + var b: Dictionary = DataLoader.get_building("castle") + assert_false(b.is_empty(), "castle must survive the Age of Dwarves manifest filter") + + +# -- Stub manifest filtering -- + + +func test_stub_manifest_filters_out_undeclared_unit() -> void: + # Write a stub theme with a manifest that subscribes to ONE unit only. + # After load_theme, every other unit should be filtered out of _data. + _write_stub_theme({ + "id": STUB_THEME_ID, + "subscribes": { + "units": ["warrior"] + } + }) + DataLoader.load_theme(STUB_THEME_ID) + assert_false( + DataLoader.get_unit("warrior").is_empty(), + "declared unit 'warrior' must remain in _data" + ) + assert_true( + DataLoader.get_unit("dwarf_founder").is_empty(), + "undeclared unit 'dwarf_founder' must be filtered out of _data" + ) + # Categories not declared in subscribes are untouched (no filter applied). + assert_false( + DataLoader.get_building("walls").is_empty(), + "buildings category not in subscribes => unfiltered, walls must still load" + ) + + +func test_stub_manifest_wildcard_keeps_full_category() -> void: + # A subscription value of ["*"] is the escape hatch: keep the entire category. + _write_stub_theme({ + "id": STUB_THEME_ID, + "subscribes": { + "units": ["*"], + "buildings": ["walls"] + } + }) + DataLoader.load_theme(STUB_THEME_ID) + assert_false( + DataLoader.get_unit("dwarf_founder").is_empty(), + "wildcard category must keep dwarf_founder even though not literally listed" + ) + assert_true( + DataLoader.get_building("castle").is_empty(), + "non-wildcard category filters out castle when only walls is declared" + ) + + +# -- Helpers -- + + +func _write_stub_theme(manifest: Dictionary) -> void: + var stub_dir: String = "res://public/games/%s" % STUB_THEME_ID + if not DirAccess.dir_exists_absolute(stub_dir): + DirAccess.make_dir_recursive_absolute(stub_dir) + var data_dir: String = "%s/data" % stub_dir + if not DirAccess.dir_exists_absolute(data_dir): + DirAccess.make_dir_absolute(data_dir) + var manifest_path: String = "%s/manifest.json" % stub_dir + var mf: FileAccess = FileAccess.open(manifest_path, FileAccess.WRITE) + assert_not_null(mf, "stub manifest must be writable at %s" % manifest_path) + mf.store_string(JSON.stringify(manifest, " ")) + mf.close() + # DataLoader.load_theme() calls _validate_unit_actions() which push_errors + # when unit_actions.json is missing. Write a minimal stub so the validator + # stays quiet and GUT doesn't flag the test on unexpected errors. + var unit_actions_path: String = "%s/unit_actions.json" % data_dir + var ua: FileAccess = FileAccess.open(unit_actions_path, FileAccess.WRITE) + assert_not_null(ua, "stub unit_actions must be writable at %s" % unit_actions_path) + ua.store_string('{"by_unit_type":{},"by_keyword":{}}') + ua.close()