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:
@@ -2,6 +2,7 @@ extends CharacterBody3D
|
||||
|
||||
signal arrived_at_target(sim_id: int)
|
||||
signal navigation_failed(sim_id: int)
|
||||
signal position_changed(sim_id: int, active_position: Vector3)
|
||||
|
||||
const DEBUG_LOGS := true
|
||||
|
||||
@@ -123,6 +124,8 @@ func _physics_process(delta: float) -> void:
|
||||
velocity.y = 0.0
|
||||
|
||||
move_and_slide()
|
||||
if not global_position.is_equal_approx(prev_pos):
|
||||
position_changed.emit(sim_id, global_position)
|
||||
|
||||
var minimum_progress := move_speed * delta * 0.1
|
||||
if global_position.distance_squared_to(prev_pos) < minimum_progress * minimum_progress:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://eji34itd1s6k
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,6 @@ func _ready() -> void:
|
||||
|
||||
func initialize_world_view() -> void:
|
||||
await get_tree().physics_frame
|
||||
spawn_initial_npcs()
|
||||
if simulation_manager.has_signal("npc_target_requested"):
|
||||
simulation_manager.npc_target_requested.connect(
|
||||
_on_npc_target_requested
|
||||
@@ -34,7 +33,7 @@ func initialize_world_view() -> void:
|
||||
simulation_manager.npc_died.connect(_on_npc_died)
|
||||
else:
|
||||
push_error("WorldViewManager: SimulationManager has no npc_died signal")
|
||||
request_current_targets()
|
||||
spawn_initial_npcs()
|
||||
|
||||
func spawn_initial_npcs() -> void:
|
||||
if simulation_manager == null:
|
||||
@@ -65,14 +64,37 @@ func spawn_npc_visual(npc: SimNPC) -> void:
|
||||
visual.navigation_failed.connect(_on_npc_visual_navigation_failed)
|
||||
else:
|
||||
push_error("WorldViewManager: NPCVisual has no navigation_failed signal")
|
||||
if visual.has_signal("position_changed"):
|
||||
visual.position_changed.connect(_on_npc_visual_position_changed)
|
||||
else:
|
||||
push_error("WorldViewManager: NPCVisual has no position_changed signal")
|
||||
active_npc_visuals[npc.id] = visual
|
||||
debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position])
|
||||
if npc.is_dead:
|
||||
visual.apply_dead_visual_state()
|
||||
elif npc.task_state == SimNPC.TASK_STATE_TRAVELING:
|
||||
simulation_manager.request_current_travel(npc.id)
|
||||
|
||||
func request_current_targets() -> void:
|
||||
func despawn_npc_visual(npc_id: int) -> bool:
|
||||
var visual = active_npc_visuals.get(npc_id) as Node3D
|
||||
if visual == null:
|
||||
return false
|
||||
simulation_manager.synchronize_npc_position(
|
||||
npc_id,
|
||||
visual.global_position
|
||||
)
|
||||
active_npc_visuals.erase(npc_id)
|
||||
visual.queue_free()
|
||||
return true
|
||||
|
||||
func reload_npc_visual(npc_id: int) -> bool:
|
||||
if active_npc_visuals.has(npc_id):
|
||||
return false
|
||||
for npc in simulation_manager.npcs:
|
||||
if npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||
continue
|
||||
_on_npc_target_requested(npc)
|
||||
if npc.id == npc_id:
|
||||
spawn_npc_visual(npc)
|
||||
return true
|
||||
return false
|
||||
|
||||
func _on_npc_target_requested(npc: SimNPC) -> void:
|
||||
var visual = active_npc_visuals.get(npc.id) as Node3D
|
||||
@@ -98,6 +120,12 @@ func _on_npc_visual_arrived(sim_id: int) -> void:
|
||||
if simulation_manager == null:
|
||||
push_error("WorldViewManager: simulation_manager missing during arrival")
|
||||
return
|
||||
var visual = active_npc_visuals.get(sim_id) as Node3D
|
||||
if visual != null:
|
||||
simulation_manager.synchronize_npc_position(
|
||||
sim_id,
|
||||
visual.global_position
|
||||
)
|
||||
simulation_manager.notify_npc_arrived(sim_id)
|
||||
|
||||
func _on_npc_visual_navigation_failed(sim_id: int) -> void:
|
||||
@@ -105,8 +133,20 @@ func _on_npc_visual_navigation_failed(sim_id: int) -> void:
|
||||
if simulation_manager == null:
|
||||
push_error("WorldViewManager: simulation_manager missing during failure")
|
||||
return
|
||||
var visual = active_npc_visuals.get(sim_id) as Node3D
|
||||
if visual != null:
|
||||
simulation_manager.synchronize_npc_position(
|
||||
sim_id,
|
||||
visual.global_position
|
||||
)
|
||||
simulation_manager.notify_npc_navigation_failed(sim_id)
|
||||
|
||||
func _on_npc_visual_position_changed(
|
||||
sim_id: int,
|
||||
active_position: Vector3
|
||||
) -> void:
|
||||
simulation_manager.synchronize_npc_position(sim_id, active_position)
|
||||
|
||||
func _on_npc_died(npc: SimNPC) -> void:
|
||||
if not active_npc_visuals.has(npc.id):
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user