feat(data-loader): Implement manifest-based resource filtering in DataLoader for selective subscriptions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-29 21:29:33 -07:00
parent 58df5e5f3d
commit 5e7509e9eb
2 changed files with 183 additions and 0 deletions

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

@ -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()