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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user