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
+4
View File
@@ -26,6 +26,8 @@ var task_duration := 0.0
var task_progress := 0.0
var task_complete := true
var target_id: StringName = &""
var travel_target_position: Vector3
var has_travel_target := false
var last_task: StringName
var random_source: RandomNumberGenerator
var debug_logs := true
@@ -62,6 +64,7 @@ func die_from_starvation() -> void:
task_progress = 0.0
task_duration = 0.0
task_complete = true
has_travel_target = false
if debug_logs:
print("[SimNPC] ", npc_name, " died from starvation.")
@@ -85,6 +88,7 @@ func set_task(action_id: StringName, duration: float = -1.0) -> void:
task_progress = 0.0
task_complete = false
task_state = TASK_STATE_TRAVELING
has_travel_target = false
func start_working() -> void:
if task_state != TASK_STATE_TRAVELING:
+31 -1
View File
@@ -182,6 +182,7 @@ func simulate_tick() -> void:
npc.task_state = SimNPC.TASK_STATE_IDLE
npc.last_task = completed_task
npc.current_task = SimulationIds.ACTION_IDLE
npc.has_travel_target = false
if needs_immediate_food_after_gather and village.food > 0:
npc.set_task(SimulationIds.ACTION_EAT, 1.0)
@@ -262,6 +263,7 @@ func notify_npc_arrived(npc_id: int) -> void:
notify_npc_navigation_failed(npc.id)
return
npc.has_travel_target = false
npc.start_working()
if debug_logs:
@@ -308,10 +310,32 @@ func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
notify_npc_target_unavailable(npc.id)
return false
npc.target_id = StringName(result.get("target_id", ""))
npc_travel_requested.emit(npc, result["position"])
npc.travel_target_position = result["position"]
npc.has_travel_target = true
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
return false
func request_current_travel(npc_id: int) -> bool:
for npc in npcs:
if npc.id != npc_id:
continue
if npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return false
if npc.has_travel_target:
npc_travel_requested.emit(npc, npc.travel_target_position)
else:
npc_target_requested.emit(npc)
return true
return false
func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool:
for npc in npcs:
if npc.id == npc_id:
npc.position = active_position
return true
return false
func register_loaded_resource_nodes() -> void:
for node in ResourceNode.get_all():
register_resource_node(node)
@@ -444,6 +468,12 @@ func get_state_snapshot() -> Dictionary:
"task_progress": npc.task_progress,
"target_id": String(npc.target_id),
"position": [npc.position.x, npc.position.y, npc.position.z],
"travel_target_position": [
npc.travel_target_position.x,
npc.travel_target_position.y,
npc.travel_target_position.z
],
"has_travel_target": npc.has_travel_target,
"starvation_ticks": npc.starvation_ticks,
"is_dead": npc.is_dead
})
+37 -3
View File
@@ -1,7 +1,8 @@
class_name NPCStateRecord
extends RefCounted
const SCHEMA_VERSION := 1
const SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1
var data: Dictionary
@@ -29,25 +30,41 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
"task_progress": npc.task_progress,
"task_complete": npc.task_complete,
"target_id": String(npc.target_id),
"travel_target_position": [
npc.travel_target_position.x,
npc.travel_target_position.y,
npc.travel_target_position.z
],
"has_travel_target": npc.has_travel_target,
"last_task": String(npc.last_task),
"random_seed": str(npc.random_source.seed),
"random_state": str(npc.random_source.state)
})
static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
var version := int(record_data.get("schema_version", -1))
if version == LEGACY_SCHEMA_VERSION:
record_data = _migrate_v1(record_data)
elif version != SCHEMA_VERSION:
return null
if not record_data.has_all([
"id", "name", "profession", "hunger", "energy", "strength",
"intelligence", "current_task", "task_state", "position",
"is_starving", "starvation_ticks", "starvation_death_threshold",
"is_dead", "task_duration", "task_progress", "task_complete",
"target_id", "last_task", "random_seed", "random_state"
"target_id", "travel_target_position", "has_travel_target",
"last_task", "random_seed", "random_state"
]):
return null
var saved_position = record_data["position"]
if not saved_position is Array or saved_position.size() != 3:
return null
var saved_target_position = record_data["travel_target_position"]
if (
not saved_target_position is Array
or saved_target_position.size() != 3
):
return null
var profession_id := StringName(record_data["profession"])
if SimulationDefinitions.get_profession(profession_id) == null:
return null
@@ -68,6 +85,16 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
return null
return NPCStateRecord.new(record_data)
static func _migrate_v1(legacy_data: Dictionary) -> Dictionary:
var migrated := legacy_data.duplicate(true)
migrated["schema_version"] = SCHEMA_VERSION
migrated["travel_target_position"] = legacy_data.get(
"position",
[0.0, 0.0, 0.0]
)
migrated["has_travel_target"] = false
return migrated
func restore(debug_logs: bool) -> SimNPC:
var random_source := RandomNumberGenerator.new()
random_source.seed = String(data["random_seed"]).to_int()
@@ -80,6 +107,7 @@ func restore(debug_logs: bool) -> SimNPC:
random_source
)
var saved_position: Array = data["position"]
var saved_target_position: Array = data["travel_target_position"]
npc.hunger = float(data["hunger"])
npc.energy = float(data["energy"])
npc.current_task = StringName(data["current_task"])
@@ -97,6 +125,12 @@ func restore(debug_logs: bool) -> SimNPC:
npc.task_progress = float(data["task_progress"])
npc.task_complete = bool(data["task_complete"])
npc.target_id = StringName(data["target_id"])
npc.travel_target_position = Vector3(
float(saved_target_position[0]),
float(saved_target_position[1]),
float(saved_target_position[2])
)
npc.has_travel_target = bool(data["has_travel_target"])
npc.last_task = StringName(data["last_task"])
npc.random_source.state = String(data["random_state"]).to_int()
npc.debug_logs = debug_logs