feat: synchronize authoritative npc position
- NpcVisual emits position_changed signal during _physics_process - SimulationManager.synchronize_npc_position() writes active position into SimNPC state on movement, arrival, and navigation failure - WorldViewManager forwards position_changed and syncs before despawn/reload - SimNPC stores travel_target_position and has_travel_target as versioned state; NPCStateRecord v2 persists them with v1 migration - WorldViewManager.spawn_npc_visual() resumes travel for traveling NPCs via SimulationManager.request_current_travel() - WorldViewManager.despawn_npc_visual() syncs position before removal - WorldViewManager.reload_npc_visual() spawns a visual from persisted state without rerolling target selection - tests/npc_visual_lifecycle_test.gd proves unload/reload preserves action, target, reservation, position, RNG state, and checksum - simulation_state_serialization_test adds legacy v1 NPC migration test that does not invent an active travel target
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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")
|
||||
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 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.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"
|
||||
)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user