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:
parent
58df5e5f3d
commit
5e7509e9eb
2 changed files with 183 additions and 0 deletions
|
|
@ -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))
|
||||
|
|
|
|||
133
src/game/engine/tests/unit/test_data_loader_manifest.gd
Normal file
133
src/game/engine/tests/unit/test_data_loader_manifest.gd
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue