diff --git a/main.tscn b/main.tscn index fa6095b..d4497e0 100644 --- a/main.tscn +++ b/main.tscn @@ -5,9 +5,7 @@ [ext_resource type="Script" uid="uid://dbb25ttlyogqr" path="res://simulation/SimulationManager.gd" id="3_lquwl"] [ext_resource type="Script" uid="uid://c1f3b26n4yntf" path="res://world/world_view_manager.gd" id="4_1bvp3"] [ext_resource type="PackedScene" uid="uid://dhxxyprqflotq" path="res://player/npc/NpcVisual.tscn" id="5_lquwl"] -[ext_resource type="PackedScene" uid="uid://q0w3en4bfk42" path="res://assets/foliage/tree.glb" id="6_5vw27"] [ext_resource type="Script" uid="uid://cjvbgqx8rao0t" path="res://world/ui/ui.gd" id="6_7mycd"] -[ext_resource type="PackedScene" uid="uid://dyu3r0cl0judq" path="res://assets/foliage/tree-high.glb" id="7_kek77"] [ext_resource type="PackedScene" uid="uid://dvd3qjpjor4sq" path="res://world/resource_nodes/ResourceNode.tscn" id="8_r3s0u"] [sub_resource type="NavigationMesh" id="NavigationMesh_lquwl"] @@ -37,12 +35,6 @@ ambient_light_source = 2 ambient_light_color = Color(1, 1, 1, 1) ambient_light_energy = 0.5 -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_5vw27"] -albedo_color = Color(0.5328383, 0.4215183, 0.18649894, 1) - -[sub_resource type="BoxMesh" id="BoxMesh_5vw27"] -material = SubResource("StandardMaterial3D_5vw27") - [sub_resource type="CylinderMesh" id="CylinderMesh_5vw27"] top_radius = 1.129 height = 5.774 @@ -82,9 +74,9 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0) script = ExtResource("1_h2yge") camera_rig = NodePath("../CameraRig") simulation_manager = NodePath("../SimulationManager") -food_zone = NodePath("../TaskZone/FoodZone") -guard_zone = NodePath("../TaskZone/GuardZone") -study_zone = NodePath("../TaskZone/StudyZone") +food_zone = NodePath("../ActivityMarkers/FoodZone") +guard_zone = NodePath("../ActivityMarkers/GuardZone") +study_zone = NodePath("../ActivityMarkers/StudyZone") stomach_capacity_for_food = 10 [node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1249497228] @@ -114,17 +106,15 @@ fov = 65.0 [node name="SimulationManager" type="Node" parent="." unique_id=1099676814] script = ExtResource("3_lquwl") -[node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent", "farm_zone", "forest_zone", "guard_zone", "study_zone", "rest_zone", "food_zone")] +[node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent", "guard_zone", "study_zone", "rest_zone", "food_zone")] script = ExtResource("4_1bvp3") npc_visual_scene = ExtResource("5_lquwl") simulation_manager = NodePath("../SimulationManager") active_npcs_parent = NodePath("../ActiveNPCs") -farm_zone = NodePath("../TaskZone/FarmZone") -forest_zone = NodePath("../TaskZone/ForestZone") -guard_zone = NodePath("../TaskZone/GuardZone") -study_zone = NodePath("../TaskZone/StudyZone") -rest_zone = NodePath("../TaskZone/RestZone") -food_zone = NodePath("../TaskZone/FoodZone") +guard_zone = NodePath("../ActivityMarkers/GuardZone") +study_zone = NodePath("../ActivityMarkers/StudyZone") +rest_zone = NodePath("../ActivityMarkers/RestZone") +food_zone = NodePath("../ActivityMarkers/FoodZone") [node name="EventBus" type="Node" parent="." unique_id=1149294963] @@ -133,60 +123,38 @@ environment = SubResource("Environment_o5qli") [node name="ActiveNPCs" type="Node3D" parent="." unique_id=88374680] -[node name="TaskZone" type="Node3D" parent="." unique_id=1700020332] +[node name="ActivityMarkers" type="Node3D" parent="." unique_id=1700020332] -[node name="FarmZone" type="Marker3D" parent="TaskZone" unique_id=786703634] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 0, -6) - -[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/FarmZone" unique_id=35196509] -transform = Transform3D(2.0639582, 0, 0, 0, 0.2, 0, 0, 0, 2.211021, -0.47271156, 0.47702956, -0.02484417) -mesh = SubResource("BoxMesh_5vw27") - -[node name="ForestZone" type="Marker3D" parent="TaskZone" unique_id=1194368101] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8, 0, -6) - -[node name="smallTree" parent="TaskZone/ForestZone" unique_id=1125781625 instance=ExtResource("6_5vw27")] -transform = Transform3D(3, 0, 0, 0, 3, 0, 0, 0, 3, 1.891, 0, -0.22) - -[node name="smallTree2" parent="TaskZone/ForestZone" unique_id=1145526891 instance=ExtResource("6_5vw27")] -transform = Transform3D(2, 0, 0, 0, 2, 0, 0, 0, 2, -0.043, 0, -1.767) - -[node name="smallTree3" parent="TaskZone/ForestZone" unique_id=759036058 instance=ExtResource("6_5vw27")] -transform = Transform3D(3, 0, 0, 0, 3, 0, 0, 0, 3, 0.13, 0, 0.614) - -[node name="highTree" parent="TaskZone/ForestZone" unique_id=1321501180 instance=ExtResource("7_kek77")] -transform = Transform3D(3, 0, 0, 0, 3, 0, 0, 0, 3, -1.539, 0, 0.171) - -[node name="GuardZone" type="Marker3D" parent="TaskZone" unique_id=1391821683] +[node name="GuardZone" type="Marker3D" parent="ActivityMarkers" unique_id=1391821683] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8) -[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/GuardZone" unique_id=2044200724] +[node name="MeshInstance3D" type="MeshInstance3D" parent="ActivityMarkers/GuardZone" unique_id=2044200724] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.354011, 0) mesh = SubResource("CylinderMesh_5vw27") -[node name="StudyZone" type="Marker3D" parent="TaskZone" unique_id=1061191663] +[node name="StudyZone" type="Marker3D" parent="ActivityMarkers" unique_id=1061191663] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0, 6) -[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/StudyZone" unique_id=338918224] +[node name="MeshInstance3D" type="MeshInstance3D" parent="ActivityMarkers/StudyZone" unique_id=338918224] transform = Transform3D(3.279775, 0, 0, 0, 5.511466, 0, 0, 0, 1, 0, 2.8180213, 0) mesh = SubResource("BoxMesh_kek77") metadata/_edit_group_ = true -[node name="RestZone" type="Marker3D" parent="TaskZone" unique_id=1187838016] +[node name="RestZone" type="Marker3D" parent="ActivityMarkers" unique_id=1187838016] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4, 0, 5) -[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/RestZone" unique_id=299533907] +[node name="MeshInstance3D" type="MeshInstance3D" parent="ActivityMarkers/RestZone" unique_id=299533907] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.4911091, 0) mesh = SubResource("PrismMesh_5vw27") -[node name="MeshInstance3D2" type="MeshInstance3D" parent="TaskZone/RestZone" unique_id=943692640] +[node name="MeshInstance3D2" type="MeshInstance3D" parent="ActivityMarkers/RestZone" unique_id=943692640] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3897309, 1.399735, 1.0352364) mesh = SubResource("PrismMesh_5vw27") -[node name="FoodZone" type="Marker3D" parent="TaskZone" unique_id=780921054] +[node name="FoodZone" type="Marker3D" parent="ActivityMarkers" unique_id=780921054] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -8) -[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/FoodZone" unique_id=357423915] +[node name="MeshInstance3D" type="MeshInstance3D" parent="ActivityMarkers/FoodZone" unique_id=357423915] transform = Transform3D(4, 0, 0, 0, 3, 0, 0, 0, 4, 0, 1.5, 0) mesh = SubResource("BoxMesh_4c57u") diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index 6753b9b..0bcceac 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -1,8 +1,6 @@ class_name SimNPC extends RefCounted -const DEBUG_LOGS := true - const TASK_STATE_IDLE := "idle" const TASK_STATE_TRAVELING := "traveling" const TASK_STATE_WORKING := "working" @@ -29,22 +27,33 @@ var task_progress := 0.0 var task_complete := true var target_id: StringName = &"" var last_task := "" +var random_source: RandomNumberGenerator +var debug_logs := true func _init( _id: int, _npc_name: String, _profession: String, _strength: float, - _intelligence: float + _intelligence: float, + _random_source: RandomNumberGenerator = null ) -> void: id = _id npc_name = _npc_name profession = _profession strength = _strength intelligence = _intelligence - hunger = randf_range(20.0, 80.0) - energy = randf_range(40.0, 100.0) - position = Vector3(randf_range(-8.0, 8.0), 0.0, randf_range(-8.0, 8.0)) + random_source = _random_source + if random_source == null: + random_source = RandomNumberGenerator.new() + random_source.seed = id + 1 + hunger = random_source.randf_range(20.0, 80.0) + energy = random_source.randf_range(40.0, 100.0) + position = Vector3( + random_source.randf_range(-8.0, 8.0), + 0.0, + random_source.randf_range(-8.0, 8.0) + ) func simulate_tick(village: SimVillage) -> void: if is_dead: @@ -104,7 +113,7 @@ func die_from_starvation() -> void: task_duration = 0.0 task_complete = true - if DEBUG_LOGS: + if debug_logs: print("[SimNPC] ", npc_name, " died from starvation.") func choose_new_task(village: SimVillage) -> void: @@ -231,9 +240,9 @@ func calculate_task_score( if task_name == last_task: score -= 1.5 - score += randf_range(0.0, 0.5) + score += random_source.randf_range(0.0, 0.5) - if DEBUG_LOGS: + if debug_logs: print( "[SimNPC] ", npc_name, diff --git a/simulation/SimVillage.gd b/simulation/SimVillage.gd index f9ae6b3..e8f1a9d 100644 --- a/simulation/SimVillage.gd +++ b/simulation/SimVillage.gd @@ -1,12 +1,11 @@ class_name SimVillage extends RefCounted -const DEBUG_LOGS := true - var food := 20.0 var wood := 10.0 var safety := 50.0 var knowledge := 0.0 +var debug_logs := true var food_modifier := 1.0 var wood_modifier := 1.0 @@ -66,7 +65,7 @@ func update_priorities() -> void: elif knowledge < 15.0: knowledge_priority = 1.25 - if DEBUG_LOGS: + if debug_logs: print(get_priority_summary()) func apply_resource_delta(resource_id: StringName, amount: float) -> void: @@ -160,5 +159,5 @@ func get_priority_summary() -> String: ] func debug_log(message: String) -> void: - if DEBUG_LOGS: + if debug_logs: print("[Village] ", message) diff --git a/simulation/SimulationClock.gd b/simulation/SimulationClock.gd new file mode 100644 index 0000000..4b131f7 --- /dev/null +++ b/simulation/SimulationClock.gd @@ -0,0 +1,22 @@ +class_name SimulationClock +extends RefCounted + +var tick_interval: float +var accumulator := 0.0 +var elapsed_ticks := 0 + +func _init(interval: float = 1.0) -> void: + tick_interval = maxf(interval, 0.0001) + +func advance(delta: float) -> int: + accumulator += maxf(delta, 0.0) + var ticks_due := 0 + while accumulator >= tick_interval: + accumulator -= tick_interval + elapsed_ticks += 1 + ticks_due += 1 + return ticks_due + +func reset() -> void: + accumulator = 0.0 + elapsed_ticks = 0 diff --git a/simulation/SimulationClock.gd.uid b/simulation/SimulationClock.gd.uid new file mode 100644 index 0000000..ae4ae6f --- /dev/null +++ b/simulation/SimulationClock.gd.uid @@ -0,0 +1 @@ +uid://ctbje7tpajoq1 diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 62be6bb..886b5eb 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -6,12 +6,14 @@ signal npc_died(npc: SimNPC) var village := SimVillage.new() -const DEBUG_LOGS := true - var npcs: Array[SimNPC] = [] -var tick_interval := 1.2 -var tick_timer := 0.0 +@export var tick_interval := 1.2 +@export var simulation_seed: int = 1337 +@export var debug_logs := true + +var clock: SimulationClock var tick_count := 0 +var wander_random_sources := {} var names := [ "Amina", "Tarik", "Jasmin" @@ -26,34 +28,56 @@ var professions := [ ] func _ready() -> void: - randomize() + clock = SimulationClock.new(tick_interval) + village.debug_logs = debug_logs village.update_modifiers() village.update_priorities() generate_npcs() - print("--- Simulation started ---") + if debug_logs: + print("--- Simulation started ---") func _process(delta: float) -> void: - tick_timer += delta - - if tick_timer >= tick_interval: - tick_timer -= tick_interval + var ticks_due := clock.advance(delta) + for tick in ticks_due: simulate_tick() func generate_npcs() -> void: for i in range(names.size()): + var npc_random := _create_random_source(i, 0) + var profession_index := npc_random.randi_range(0, professions.size() - 1) var npc := SimNPC.new( i, names[i], - professions.pick_random(), - randf_range(1.0, 10.0), - randf_range(1.0, 10.0) + professions[profession_index], + npc_random.randf_range(1.0, 10.0), + npc_random.randf_range(1.0, 10.0), + npc_random ) + npc.debug_logs = debug_logs npcs.append(npc) + wander_random_sources[i] = _create_random_source(i, 1) + +func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator: + var source := RandomNumberGenerator.new() + source.seed = simulation_seed + (npc_id + 1) * 1000003 + stream_id * 7919 + return source + +func get_wander_offset(npc_id: int) -> Vector3: + var source: RandomNumberGenerator = wander_random_sources.get(npc_id) + if source == null: + source = _create_random_source(npc_id, 1) + wander_random_sources[npc_id] = source + return Vector3( + source.randf_range(-6.0, 6.0), + 0.0, + source.randf_range(-6.0, 6.0) + ) func simulate_tick() -> void: tick_count += 1 - print("--- Tick ", tick_count, " ---") + if debug_logs: + print("--- Tick ", tick_count, " ---") var village_was_changed := false @@ -75,7 +99,7 @@ func simulate_tick() -> void: npc_died.emit(npc) npc_task_changed.emit(npc, old_task, npc.current_task) - if DEBUG_LOGS: + if debug_logs: print("[SimulationManager] NPC died: ", npc.npc_name) if old_task != npc.current_task and not npc.is_dead: @@ -91,14 +115,14 @@ func simulate_tick() -> void: var extracted := node.extract() if extracted > 0: village.apply_resource_delta(node.resource_id, extracted) - if DEBUG_LOGS: + if debug_logs: print("[SimulationManager] ", npc.npc_name, " extracted ", extracted, " ", node.resource_id, " from ", node.node_id) - elif DEBUG_LOGS: + elif debug_logs: print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": target reservation was invalid") release_npc_reservation(npc.id) elif completed_task not in ["gather_food", "gather_wood"]: village.apply_npc_task(npc) - elif DEBUG_LOGS: + elif debug_logs: print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": no resource target") village_was_changed = true @@ -112,10 +136,10 @@ func simulate_tick() -> void: npc.set_task("eat", 1.0) npc_task_changed.emit(npc, completed_task, npc.current_task) - if DEBUG_LOGS: + if debug_logs: print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.") - if DEBUG_LOGS: + if debug_logs: print( npc.npc_name, " | ", @@ -148,7 +172,7 @@ func simulate_tick() -> void: if village_was_changed: village_changed.emit(village) - if DEBUG_LOGS: + if debug_logs: print(village.get_summary()) func set_npc_target_id(npc_id: int, target_id: StringName) -> void: @@ -177,14 +201,14 @@ func notify_npc_arrived(npc_id: int) -> void: if npc.target_id != &"": var node := ResourceNode.get_by_id(npc.target_id) if node == null or not node.can_extract(): - if DEBUG_LOGS: + if debug_logs: print("[SimulationManager] ", npc.npc_name, " arrived but target ", npc.target_id, " is unavailable, replanning") notify_npc_navigation_failed(npc.id) return npc.start_working() - if DEBUG_LOGS: + if debug_logs: print("[SimulationManager] ", npc.npc_name, " arrived and started working on: ", npc.current_task) return @@ -201,7 +225,7 @@ func notify_npc_navigation_failed(npc_id: int) -> void: npc.set_task("wander", 2.0) npc_task_changed.emit(npc, failed_task, npc.current_task) - if DEBUG_LOGS: + if debug_logs: print("[SimulationManager] ", npc.npc_name, " could not navigate for ", failed_task, " and is trying a wander target") return @@ -219,7 +243,7 @@ func harvest_resource_node(node: ResourceNode) -> float: village.apply_resource_delta(node.resource_id, extracted) village_changed.emit(village) - if DEBUG_LOGS: + if debug_logs: print("[SimulationManager] Player extracted ", extracted, " ", node.resource_id, " from ", node.node_id) return extracted @@ -250,3 +274,39 @@ func get_starving_count() -> int: if npc.is_starving: count += 1 return count + +func get_state_snapshot() -> Dictionary: + var npc_snapshots: Array[Dictionary] = [] + for npc in npcs: + npc_snapshots.append({ + "id": npc.id, + "name": npc.npc_name, + "profession": npc.profession, + "hunger": npc.hunger, + "energy": npc.energy, + "strength": npc.strength, + "intelligence": npc.intelligence, + "task": npc.current_task, + "task_state": npc.task_state, + "task_duration": npc.task_duration, + "task_progress": npc.task_progress, + "target_id": String(npc.target_id), + "position": [npc.position.x, npc.position.y, npc.position.z], + "starvation_ticks": npc.starvation_ticks, + "is_dead": npc.is_dead + }) + + return { + "seed": simulation_seed, + "tick_count": tick_count, + "village": { + "food": village.food, + "wood": village.wood, + "safety": village.safety, + "knowledge": village.knowledge + }, + "npcs": npc_snapshots + } + +func get_state_checksum() -> String: + return JSON.stringify(get_state_snapshot()).sha256_text() diff --git a/tests/deterministic_simulation_test.gd b/tests/deterministic_simulation_test.gd new file mode 100644 index 0000000..12920c2 --- /dev/null +++ b/tests/deterministic_simulation_test.gd @@ -0,0 +1,52 @@ +extends SceneTree + +var failures: Array[String] = [] + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + var clock := SimulationClock.new(1.0) + _check(clock.advance(0.4) == 0, "Clock should not tick before its interval") + _check(clock.advance(0.6) == 1, "Clock should emit one tick at its interval") + _check(clock.advance(2.4) == 2, "Clock should preserve remainder across multiple ticks") + _check(clock.elapsed_ticks == 3, "Clock should track elapsed simulation ticks") + + var first := _run_scenario(424242) + var second := _run_scenario(424242) + var different := _run_scenario(424243) + + _check(first == second, "The same seed and commands should produce the same checksum") + _check(first != different, "A different seed should change the scenario checksum") + + if failures.is_empty(): + print("[TEST] Deterministic simulation passed | checksum=", first) + quit(0) + return + + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + +func _run_scenario(seed_value: int) -> String: + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.simulation_seed = seed_value + manager.debug_logs = false + root.add_child(manager) + manager.set_process(false) + + for step in 24: + manager.simulate_tick() + for npc in manager.npcs: + if npc.task_state == SimNPC.TASK_STATE_TRAVELING: + manager.notify_npc_arrived(npc.id) + + var snapshot: Dictionary = manager.get_state_snapshot() + snapshot.erase("seed") + var checksum := JSON.stringify(snapshot).sha256_text() + manager.free() + return checksum + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/deterministic_simulation_test.gd.uid b/tests/deterministic_simulation_test.gd.uid new file mode 100644 index 0000000..6895287 --- /dev/null +++ b/tests/deterministic_simulation_test.gd.uid @@ -0,0 +1 @@ +uid://bexyksljoddd0 diff --git a/tests/flat_map_baseline_test.gd b/tests/flat_map_baseline_test.gd index 82ab8fb..c525540 100644 --- a/tests/flat_map_baseline_test.gd +++ b/tests/flat_map_baseline_test.gd @@ -28,10 +28,10 @@ func _run() -> void: ] var destinations: Array[Vector3] = [] for marker_path in [ - "TaskZone/FoodZone", - "TaskZone/GuardZone", - "TaskZone/StudyZone", - "TaskZone/RestZone" + "ActivityMarkers/FoodZone", + "ActivityMarkers/GuardZone", + "ActivityMarkers/StudyZone", + "ActivityMarkers/RestZone" ]: destinations.append(main_scene.get_node(marker_path).global_position) diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index 273c6c5..b3cc00e 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -6,8 +6,6 @@ const DEBUG_LOGS := true @export var simulation_manager: Node @export var active_npcs_parent: Node3D -@export var farm_zone: Marker3D -@export var forest_zone: Marker3D @export var guard_zone: Marker3D @export var study_zone: Marker3D @export var rest_zone: Marker3D @@ -98,7 +96,7 @@ func update_npc_targets() -> void: continue if task == "wander": - var offset = Vector3(randf_range(-6.0, 6.0), 0.0, randf_range(-6.0, 6.0)) + var offset := _get_wander_offset(npc.id) visual.set_target_position(visual.global_position + offset) continue @@ -117,10 +115,6 @@ func update_npc_targets() -> void: func get_target_for_task(task: String) -> Marker3D: match task: - "gather_food": - return farm_zone - "gather_wood": - return forest_zone "patrol": return guard_zone "study": @@ -175,7 +169,7 @@ func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> vo return if new_task == "wander": - var offset := Vector3(randf_range(-6.0, 6.0), 0.0, randf_range(-6.0, 6.0)) + var offset := _get_wander_offset(npc.id) visual.set_target_position(visual.global_position + offset) return @@ -190,6 +184,12 @@ func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> vo if target != null: visual.set_target_position(target.global_position) + +func _get_wander_offset(npc_id: int) -> Vector3: + if simulation_manager != null and simulation_manager.has_method("get_wander_offset"): + return simulation_manager.get_wander_offset(npc_id) + push_error("WorldViewManager: SimulationManager cannot provide deterministic wander offsets") + return Vector3.ZERO func _on_npc_visual_arrived(sim_id: int) -> void: debug_log("NPC visual arrived. sim_id=%s" % sim_id)