Files
gamedev-the-steward/tests/npc_visual_lifecycle_test.gd
T

263 lines
9.7 KiB
GDScript

extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene)
await process_frame
await physics_frame
await physics_frame
var manager: Node = main_scene.get_node("SimulationManager")
var view: Node = main_scene.get_node("WorldViewManager")
var ui: CanvasLayer = main_scene.get_node("UI")
manager.set_process(false)
var npc: SimNPC = manager.npcs[0]
var visual: Node3D = view.active_npc_visuals[npc.id]
visual.set_physics_process(false)
var profession_definition := SimulationDefinitions.get_profession(npc.profession)
var body: MeshInstance3D = visual.get_node("MeshInstance3D")
var trust_reaction_root: Node3D = visual.get_node("TrustReactionRoot")
_check(not trust_reaction_root.visible, "A newly spawned visual should not replay reactions")
_check(
(
body.material_override is ShaderMaterial
and (
body.material_override.get_shader_parameter("coat_color")
== profession_definition.visual_color
)
),
"NPC cel material should retain its authoritative profession color"
)
_check(
(
body.mesh is ArrayMesh
and visual.has_node("Head")
and visual.has_node("ArmLeft")
and visual.get_node("Tail").mesh is ArrayMesh
),
"NPC presentation should use the Blender animal body and tail"
)
var original_species: StringName = visual.get_meta("animal_species")
_check(
visual.get_node("TaskGlyphRoot").position.y >= 2.9,
"Task cues must sit above the animal cast's tall ears"
)
_check(
(
visual.get_node("ProfessionProp").mesh != null
and profession_definition.display_name in visual.get_node("ProfessionLabel").text
),
"NPC visual should expose a profession prop and readable label"
)
visual.set_task_presentation(SimulationIds.ACTION_GATHER_FOOD, SimNPC.TASK_STATE_TRAVELING)
_check(
(
visual.get_node("TaskGlyphRoot").visible
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh
),
"NPC visual should expose a compact task glyph for cinematic readability"
)
visual.set_debug_overlay_visible(false)
_check(
not visual.get_node("ProfessionLabel").visible and visual.get_node("TaskGlyphRoot").visible,
"Cinematic mode should hide debug labels while preserving task glyphs"
)
visual.play_trust_gain_reaction()
_check(trust_reaction_root.visible, "A direct trust reaction should reveal the blossom")
visual.play_trust_gain_reaction()
_check(
trust_reaction_root.visible and trust_reaction_root.get_child_count() == 4,
"Restarting a trust reaction should reuse one blossom without stacking visuals"
)
visual.set_debug_overlay_visible(false)
_check(
trust_reaction_root.visible,
"The trust reaction should remain visible in cinematic presentation mode"
)
var active_reaction_tween: Tween = visual.get("trust_reaction_tween")
await active_reaction_tween.finished
_check(
not trust_reaction_root.visible,
"The trust reaction should fade away instead of becoming permanent world clutter"
)
visual.set_debug_overlay_visible(true)
visual.set_task_presentation(SimulationIds.ACTION_WANDER, SimNPC.TASK_STATE_TRAVELING)
_check(
not visual.get_node("TaskGlyphRoot").visible,
"Ambient wander should not display a task glyph"
)
var routed_observer: SimNPC = manager.npcs[1]
var routed_subject: SimNPC = manager.npcs[2]
var observer_visual: Node3D = view.active_npc_visuals[routed_observer.id]
var subject_visual: Node3D = view.active_npc_visuals[routed_subject.id]
var routed_relationship := RelationshipStateRecord.create(
routed_observer.id, routed_subject.id, 0.5, 0.65, 9001
)
var routed_event := EconomicEventRecord.create(
9001,
SimulationIds.EVENT_STORAGE_DEPOSITED,
manager.tick_count,
routed_subject.id,
&"test_source",
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
manager.emit_signal("relationship_changed", routed_relationship, routed_event)
_check(
observer_visual.get_node("TrustReactionRoot").visible,
"The authoritative relationship signal should route a reaction to its observer"
)
_check(
not subject_visual.get_node("TrustReactionRoot").visible,
"A routed trust reaction should not play on the relationship subject"
)
var test_decision := ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD,
-1.0,
"Test shortage reason",
{SimulationIds.ACTION_GATHER_FOOD: 4.5, SimulationIds.ACTION_PATROL: -1000000.0},
{SimulationIds.ACTION_PATROL: "Needs 1 Wood (0.5 available)"}
)
manager.latest_decisions[npc.id] = test_decision
manager.emit_signal("npc_decision_recorded", npc, test_decision)
var inspector_label: Label = ui.get_node("NpcInspectorPanel/MarginContainer/NpcInspectorLabel")
_check(
"Test shortage reason" in inspector_label.text and npc.npc_name in inspector_label.text,
"NPC inspector should show the selected villager's real decision reason"
)
_check(
(
"Unavailable" in inspector_label.text
and "Patrol — Needs 1 Wood" in inspector_label.text
and "-1000000" not in inspector_label.text
),
"NPC inspector should explain rejected actions without sentinel utility scores"
)
var carried_food: MeshInstance3D = visual.get_node("CarriedFood")
_check(not carried_food.visible, "NPC should begin without a carried-food prop")
npc.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
manager.emit_signal("npc_inventory_changed", npc, SimulationIds.RESOURCE_FOOD, 1.0)
_check(carried_food.visible, "Carried food should be visible on the NPC")
npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
manager.emit_signal("npc_inventory_changed", npc, SimulationIds.RESOURCE_FOOD, 0.0)
_check(not carried_food.visible, "Deposited or eaten food should be hidden")
var carried_wood: Node3D = visual.get_node("CarriedWood")
_check(not carried_wood.visible, "NPC should begin without carried logs")
npc.add_inventory(SimulationIds.RESOURCE_WOOD, 2.0)
manager.emit_signal("npc_inventory_changed", npc, SimulationIds.RESOURCE_WOOD, 2.0)
_check(carried_wood.visible, "Carried wood should show a visible log bundle")
npc.remove_inventory(SimulationIds.RESOURCE_WOOD, 2.0)
manager.emit_signal("npc_inventory_changed", npc, SimulationIds.RESOURCE_WOOD, 0.0)
_check(not carried_wood.visible, "Deposited wood should hide the log bundle")
var resource_state: ResourceStateRecord = manager.get_resource_state(&"berry_bush_01")
npc.set_task(SimulationIds.ACTION_GATHER_FOOD)
npc.target_id = resource_state.get_node_id()
npc.travel_target_position = Vector3(-6.0, 0.0, -8.0)
npc.has_travel_target = true
resource_state.reserve(npc.id)
manager.request_current_travel(npc.id)
var saved_action := npc.current_task
var saved_target_id := npc.target_id
var saved_target_position := npc.travel_target_position
var authoritative_position := Vector3(-2.5, 0.5, 1.75)
visual.global_position = authoritative_position
_check(view.despawn_npc_visual(npc.id), "Visual should unload")
await process_frame
_check(
npc.position.is_equal_approx(authoritative_position),
"Unload should synchronize the last active position"
)
_check(
(
npc.current_task == saved_action
and npc.target_id == saved_target_id
and npc.travel_target_position.is_equal_approx(saved_target_position)
),
"Unload should preserve action and target state"
)
_check(
resource_state.get_reserved_by() == npc.id,
"Unload should preserve the resource reservation"
)
var unloaded_checksum: String = manager.get_state_checksum()
_check(view.reload_npc_visual(npc.id), "Visual should reload")
var reloaded: Node3D = view.active_npc_visuals[npc.id]
reloaded.set_physics_process(false)
_check(
reloaded.get_meta("animal_species") == original_species,
"Reload must derive the same animal identity without saved appearance or RNG"
)
_check(
reloaded.global_position.is_equal_approx(authoritative_position),
"Reloaded visual should use authoritative position"
)
var resumed_target: Vector3 = reloaded.get("current_target")
_check(
resumed_target.is_equal_approx(saved_target_position),
"Reloaded visual should resume the persisted travel target"
)
_check(
manager.get_state_checksum() == unloaded_checksum,
"Loading a visual must not mutate simulation state"
)
_check(resource_state.get_reserved_by() == npc.id, "Reload should preserve the reservation")
var moved_position := authoritative_position + Vector3(0.5, 0.0, 0.25)
reloaded.emit_signal("position_changed", npc.id, moved_position)
_check(
npc.position.is_equal_approx(moved_position),
"Active position callback should synchronize simulation state"
)
var saved_state: String = manager.serialize_state()
var visual_before_restore: Node3D = view.active_npc_visuals[npc.id]
npc.hunger = 99.0
_check(
manager.restore_state_from_json(saved_state),
"Visual lifecycle scenario should restore valid state"
)
var visual_after_restore: Node3D = view.active_npc_visuals[npc.id]
_check(
visual_after_restore != visual_before_restore,
"State restoration should rebuild active NPC presentation"
)
_check(
visual_after_restore.global_position.is_equal_approx(npc.position),
"Rebuilt presentation should use the restored authoritative position"
)
_check(
not visual_after_restore.get_node("TrustReactionRoot").visible,
"Restoring state should rebuild the visual without replaying transient reactions"
)
visual_after_restore.play_trust_gain_reaction()
visual_after_restore.apply_dead_visual_state()
_check(
not visual_after_restore.get_node("TrustReactionRoot").visible,
"Death presentation should suppress an active trust reaction"
)
if failures.is_empty():
print("[TEST] NPC visual lifecycle passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)