refactor: separate content from presentation assets

This commit is contained in:
Rijad Zuzo
2026-08-12 21:41:33 +02:00
parent 5a70aaaded
commit bf4bf73675
22 changed files with 480 additions and 70 deletions
+27 -4
View File
@@ -51,9 +51,10 @@ func _run() -> void:
(
goat_definition.behavior_id == AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE
and sheep_definition.behavior_id == AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE
and goat_definition.presentation_scene_path == sheep_definition.presentation_scene_path
and goat_definition.presentation_cue_id == PresentationCueIds.ANIMAL_DOMESTIC_GOAT
and sheep_definition.presentation_cue_id == PresentationCueIds.ANIMAL_DOMESTIC_SHEEP
),
"Both animals should reuse one grazer behavior and presentation contract"
"Both animals should reuse one grazer behavior with stable presentation cues"
)
_check(
(
@@ -63,7 +64,29 @@ func _run() -> void:
"Both data definitions should advertise the existing care action"
)
var generic_scene := load(goat_definition.presentation_scene_path) as PackedScene
var presentation_catalog := PresentationCatalog.create_core()
_check(
presentation_catalog != null and presentation_catalog.is_valid(),
"The presentation-side catalog should validate independently"
)
if presentation_catalog == null or not presentation_catalog.is_valid():
_finish()
return
var goat_cue := presentation_catalog.get_definition(goat_definition.presentation_cue_id)
var sheep_cue := presentation_catalog.get_definition(sheep_definition.presentation_cue_id)
_check(
(
goat_cue != null
and sheep_cue != null
and goat_cue.scene_path == sheep_cue.scene_path
and goat_cue.scene_path == "res://world/animals/grazer/cozy_grazer.tscn"
),
"Presentation cues should map both data-only animals to the shared grazer scene"
)
if goat_cue == null or sheep_cue == null:
_finish()
return
var generic_scene := load(goat_cue.scene_path) as PackedScene
var generic := generic_scene.instantiate() as AnimalNode
_check(
(
@@ -79,7 +102,7 @@ func _run() -> void:
if generic != null:
generic.free()
var factory := AnimalFactory.new(catalog)
var factory := AnimalFactory.new(catalog, presentation_catalog)
var goat := (
factory
. create_presentation(
+1 -1
View File
@@ -257,7 +257,7 @@ func _animal(animal_definition_id: StringName, action_id: StringName) -> AnimalD
animal.species_id = &"test_species"
animal.display_name = String(animal_definition_id).capitalize()
animal.supported_action_ids = [action_id]
animal.presentation_scene_path = "res://world/animals/grazer/cozy_grazer.tscn"
animal.presentation_cue_id = PresentationCueIds.ANIMAL_DOMESTIC_GOAT
return animal
+148
View File
@@ -0,0 +1,148 @@
extends GutTest
func test_headless_animal_content_uses_only_stable_cues_and_never_asset_paths() -> void:
var content := ContentCatalog.create_core()
assert_true(content.is_valid(), "%s" % [content.get_errors()])
var animals := content.get_animals()
assert_eq(animals.size(), 2)
for animal in animals:
assert_true(AnimalDefinition.is_valid_stable_id(animal.presentation_cue_id))
assert_false("res://" in String(animal.presentation_cue_id))
assert_false(_resource_has_asset_path_property(animal))
assert_true(animal.validate().is_empty())
var missing_asset_cue := PresentationCueDefinition.new()
missing_asset_cue.cue_id = &"animal.missing_asset"
missing_asset_cue.scene_path = "res://does/not/exist/animal.tscn"
assert_true(missing_asset_cue.validate_metadata().is_empty())
assert_false(missing_asset_cue.validate_assets().is_empty())
var headless_catalog := ContentCatalog.new()
var pack := SimulationContentPack.new()
pack.pack_id = &"headless_animals"
pack.display_name = "Headless animals"
pack.animals = [_animal(&"headless_goat", missing_asset_cue.cue_id)]
var errors := headless_catalog.rebuild(
[pack] as Array[SimulationContentPack], [], [missing_asset_cue.cue_id]
)
assert_true(errors.is_empty(), "%s" % [errors])
assert_true(headless_catalog.is_valid())
errors = headless_catalog.rebuild(
[pack] as Array[SimulationContentPack], [], [&"animal.some_other_cue"]
)
assert_true(_has_error(errors, "unknown presentation cue 'animal.missing_asset'"))
assert_false(headless_catalog.is_valid())
func test_presentation_catalog_rejects_missing_assets_and_duplicate_cues_transactionally() -> void:
var missing_id := PresentationCueDefinition.new()
var catalog := PresentationCatalog.new()
var errors := catalog.rebuild([missing_id] as Array[PresentationCueDefinition], false)
assert_true(_has_error(errors, "cue_id is invalid"))
assert_false(catalog.is_valid())
var valid := _cue(&"animal.valid", "res://world/animals/grazer/cozy_grazer.tscn")
var duplicate := _cue(&"animal.valid", "res://world/animals/goat/cozy_goat.tscn")
errors = catalog.rebuild([valid, duplicate] as Array[PresentationCueDefinition], false)
assert_true(_has_error(errors, "Duplicate presentation cue 'animal.valid'"))
assert_false(catalog.is_valid())
assert_true(catalog.get_cue_ids().is_empty())
assert_null(catalog.get_definition(&"animal.valid"))
var missing := _cue(&"animal.missing", "res://world/animals/absent.tscn")
errors = catalog.rebuild([missing] as Array[PresentationCueDefinition], true)
assert_true(_has_error(errors, "scene_path does not resolve"), "%s" % [errors])
assert_false(catalog.is_valid())
assert_true(catalog.get_cue_ids().is_empty())
func test_goat_and_sheep_are_data_only_content_additions_using_one_scene_contract() -> void:
var animal_catalog := AnimalCatalog.create_core()
var presentation_catalog := PresentationCatalog.create_core()
assert_not_null(animal_catalog)
assert_true(presentation_catalog.is_valid(), "%s" % [presentation_catalog.get_errors()])
assert_eq(
presentation_catalog.get_cue_ids(),
[PresentationCueIds.ANIMAL_DOMESTIC_GOAT, PresentationCueIds.ANIMAL_DOMESTIC_SHEEP]
)
var goat := animal_catalog.get_definition(&"domestic_goat")
var sheep := animal_catalog.get_definition(&"domestic_sheep")
var goat_cue := presentation_catalog.get_definition(goat.presentation_cue_id)
var sheep_cue := presentation_catalog.get_definition(sheep.presentation_cue_id)
assert_eq(goat.behavior_id, AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE)
assert_eq(sheep.behavior_id, AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE)
assert_ne(goat.presentation_cue_id, sheep.presentation_cue_id)
assert_eq(goat_cue.scene_path, sheep_cue.scene_path)
assert_eq(goat_cue.animation_hook_id, sheep_cue.animation_hook_id)
assert_eq(goat_cue.animation_hook_id, AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE)
assert_true(bool(goat_cue.parameters["show_horns"]))
assert_false(bool(sheep_cue.parameters["show_horns"]))
var factory := AnimalFactory.new(animal_catalog, presentation_catalog)
var goat_node := factory.create_presentation(&"domestic_goat", &"factory_goat", "Goat")
var sheep_node := factory.create_presentation(&"domestic_sheep", &"factory_sheep", "Sheep")
assert_not_null(goat_node)
assert_not_null(sheep_node)
add_child_autofree(goat_node)
add_child_autofree(sheep_node)
await wait_process_frames(1)
assert_eq(goat_node.get_script(), sheep_node.get_script())
assert_eq(goat_node.get_node("Visual").get_script(), sheep_node.get_node("Visual").get_script())
assert_true(goat_node.get_node("Visual/HeadRoot/HornLeft").visible)
assert_false(sheep_node.get_node("Visual/HeadRoot/HornLeft").visible)
func test_factory_requires_an_injected_mapping_for_every_animal_cue() -> void:
var animal_catalog := AnimalCatalog.create_core()
var headless_factory := AnimalFactory.new(animal_catalog)
assert_null(headless_factory.get_presentation_catalog())
assert_eq(headless_factory.last_error, "")
assert_null(headless_factory.create_presentation(&"domestic_goat", &"headless_goat", "Goat"))
assert_true("must be injected" in headless_factory.last_error)
var presentation_catalog := PresentationCatalog.new()
var only_goat := PresentationCatalog.create_core().get_definition(
PresentationCueIds.ANIMAL_DOMESTIC_GOAT
)
assert_true(
presentation_catalog.rebuild([only_goat] as Array[PresentationCueDefinition]).is_empty()
)
var factory := AnimalFactory.new(animal_catalog, presentation_catalog)
assert_null(factory.create_presentation(&"domestic_sheep", &"missing_sheep", "Sheep"))
assert_true("Unknown presentation cue 'animal.domestic_sheep'" in factory.last_error)
func _animal(animal_id: StringName, cue_id: StringName) -> AnimalDefinition:
var animal := AnimalDefinition.new()
animal.animal_definition_id = animal_id
animal.species_id = &"test_species"
animal.display_name = "Headless animal"
animal.supported_action_ids = []
animal.presentation_cue_id = cue_id
return animal
func _cue(cue_id: StringName, scene_path: String) -> PresentationCueDefinition:
var cue := PresentationCueDefinition.new()
cue.cue_id = cue_id
cue.scene_path = scene_path
return cue
func _resource_has_asset_path_property(resource: Resource) -> bool:
for property in resource.get_property_list():
if (int(property["usage"]) & PROPERTY_USAGE_SCRIPT_VARIABLE) == 0:
continue
var property_name := String(property["name"])
if property_name.ends_with("_path") or property_name in ["scene", "icon", "material"]:
return true
return false
func _has_error(errors: Array[String], fragment: String) -> bool:
for error in errors:
if fragment in error:
return true
return false
@@ -0,0 +1 @@
uid://7leb0ri32j4u