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:
2026-07-05 17:21:17 +02:00
parent 4f9dc842fd
commit a8ae331f51
8 changed files with 238 additions and 10 deletions
@@ -8,6 +8,7 @@ func _initialize() -> void:
func _run() -> void:
await _test_deterministic_continuation()
await _test_resource_state_round_trip()
_test_legacy_npc_migration()
_test_legacy_resource_migration()
_test_schema_rejection()
@@ -160,6 +161,27 @@ func _test_legacy_resource_migration() -> void:
"Resource migration should preserve mutable authority"
)
func _test_legacy_npc_migration() -> void:
var npc := SimNPC.new(
88,
"LegacyNPC",
SimulationIds.PROFESSION_FARMER,
5.0,
5.0
)
var legacy_data := NPCStateRecord.capture(npc).to_dictionary()
legacy_data["schema_version"] = NPCStateRecord.LEGACY_SCHEMA_VERSION
legacy_data.erase("travel_target_position")
legacy_data.erase("has_travel_target")
var migrated := NPCStateRecord.from_dictionary(legacy_data)
_check(migrated != null, "NPCStateRecord v1 should migrate explicitly")
if migrated == null:
return
_check(
not bool(migrated.data["has_travel_target"]),
"Legacy NPC migration should not invent an active travel target"
)
func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value