diff --git a/src/game/engine/src/autoloads/data_loader.gd b/src/game/engine/src/autoloads/data_loader.gd index cb6b72b8..e1d256da 100644 --- a/src/game/engine/src/autoloads/data_loader.gd +++ b/src/game/engine/src/autoloads/data_loader.gd @@ -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//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)) 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()