Files
gamedev-the-steward/world/world_view_manager.gd
T
admin 37610242bf feat: add deterministic simulation foundation
Replace implicit randomness with seeded per-NPC RandomNumberGenerator
streams so the same seed always produces the same scenario outcome.

- Add SimulationClock (RefCounted): explicit fixed-step clock that
  converts frame delta into simulation ticks while preserving sub-tick
  remainder; replaces raw _process tick_timer accumulation
- Add simulation_seed export (int=1337) to SimulationManager; drive
  per-NPC RNG streams via seed + npc_id + stream_id derivation
  (stream 0=init, stream 1=wander)
- Add get_wander_offset(npc_id) to SimulationManager so wander targets
  are deterministic per seed; WorldViewManager calls it instead of
  local randf_range()
- Add get_state_snapshot() and get_state_checksum() to SimulationManager
  for headless verification and future save/load
- Convert SimNPC/SimVillage const DEBUG_LOGS to instance var debug_logs
  so headless tests can silence output without recompilation
- Pass RandomNumberGenerator through SimNPC._init() instead of using
  global randf_range() for hunger/energy/position/scoring noise
- Remove obsolete farm_zone and forest_zone exports from
  WorldViewManager and their marker/tree scene children from main.tscn;
  NPC gather tasks use ResourceNode exclusively
- Rename TaskZone container to ActivityMarkers in main.tscn and update
  all scene paths (player, WVM, tests) to match
- Add headless deterministic_simulation_test.gd: verifies clock
  accuracy, same-seed equality, different-seed divergence, and
  24-tick scenario checksum without loading main.tscn
2026-07-05 11:23:12 +02:00

228 lines
6.5 KiB
GDScript

extends Node
const DEBUG_LOGS := true
@export var npc_visual_scene: PackedScene
@export var simulation_manager: Node
@export var active_npcs_parent: Node3D
@export var guard_zone: Marker3D
@export var study_zone: Marker3D
@export var rest_zone: Marker3D
@export var food_zone: Marker3D
var active_npc_visuals := {}
func debug_log(message: String) -> void:
if DEBUG_LOGS:
print("[WorldViewManager] ", message)
func _ready() -> void:
call_deferred("initialize_world_view")
func initialize_world_view() -> void:
await get_tree().physics_frame
spawn_initial_npcs()
if simulation_manager.has_signal("npc_task_changed"):
simulation_manager.npc_task_changed.connect(_on_npc_task_changed)
else:
push_error("WorldViewManager: SimulationManager has no npc_task_changed signal")
if simulation_manager.has_signal("npc_died"):
simulation_manager.npc_died.connect(_on_npc_died)
else:
push_error("WorldViewManager: SimulationManager has no npc_died signal")
update_npc_targets()
func spawn_initial_npcs() -> void:
if simulation_manager == null:
push_error("WorldViewManager: simulation_manager is missing")
return
if npc_visual_scene == null:
push_error("WorldViewManager: npc_visual_scene is missing")
return
if active_npcs_parent == null:
push_error("WorldViewManager: active_npcs_parent is missing")
return
debug_log("Spawning NPC visuals. Count: %s" % simulation_manager.npcs.size())
for npc in simulation_manager.npcs:
spawn_npc_visual(npc)
func spawn_npc_visual(npc: SimNPC) -> void:
if active_npc_visuals.has(npc.id):
return
var visual = npc_visual_scene.instantiate()
active_npcs_parent.add_child(visual)
visual.global_position = npc.position
visual.setup_from_sim(npc)
if visual.has_signal("arrived_at_target"):
visual.arrived_at_target.connect(_on_npc_visual_arrived)
else:
push_error("WorldViewManager: NPCVisual has no arrived_at_target signal")
if visual.has_signal("navigation_failed"):
visual.navigation_failed.connect(_on_npc_visual_navigation_failed)
else:
push_error("WorldViewManager: NPCVisual has no navigation_failed signal")
active_npc_visuals[npc.id] = visual
debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position])
func update_npc_targets() -> void:
if simulation_manager == null:
return
for npc in simulation_manager.npcs:
if not active_npc_visuals.has(npc.id):
continue
var visual = active_npc_visuals[npc.id]
var task = npc.current_task
if task in ["", "idle", "dead"]:
continue
var resource_pos = _try_get_resource_target(npc, npc.current_task)
if resource_pos != null:
visual.set_target_position(resource_pos)
continue
if task == "wander":
var offset := _get_wander_offset(npc.id)
visual.set_target_position(visual.global_position + offset)
continue
if task in ["gather_food", "gather_wood"]:
if simulation_manager.has_method("notify_npc_target_unavailable"):
simulation_manager.notify_npc_target_unavailable(npc.id)
continue
var target = get_target_for_task(task)
if target == null:
debug_log("No target for task: %s" % task)
continue
visual.set_target_position(target.global_position)
func get_target_for_task(task: String) -> Marker3D:
match task:
"patrol":
return guard_zone
"study":
return study_zone
"rest":
return rest_zone
"eat":
return food_zone
_:
return rest_zone
func _try_get_resource_target(npc: SimNPC, task: String) -> Variant:
var action_map := {
"gather_food": &"gather_food",
"gather_wood": &"gather_wood"
}
var action_id = action_map.get(task)
if action_id == null:
return null
var visual = active_npc_visuals.get(npc.id) as Node3D
if visual == null:
return null
var node := ResourceNode.find_available(action_id, npc.id, visual.global_position)
if node == null:
debug_log("No available resource node for %s" % task)
return null
if not node.reserve(npc.id):
debug_log("Failed to reserve %s for %s" % [node.node_id, npc.npc_name])
return null
npc.target_id = node.node_id
debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task])
return node.interaction_point.global_position
func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> void:
if DEBUG_LOGS:
debug_log("%s changed task: %s -> %s" % [npc.npc_name, old_task, new_task])
if not active_npc_visuals.has(npc.id):
return
if new_task in ["", "idle", "dead"]:
return
var visual = active_npc_visuals[npc.id]
var resource_pos = _try_get_resource_target(npc, new_task)
if resource_pos != null:
visual.set_target_position(resource_pos)
return
if new_task == "wander":
var offset := _get_wander_offset(npc.id)
visual.set_target_position(visual.global_position + offset)
return
if new_task in ["gather_food", "gather_wood"]:
if simulation_manager.has_method("notify_npc_target_unavailable"):
simulation_manager.notify_npc_target_unavailable(npc.id)
else:
push_error("WorldViewManager: SimulationManager cannot handle unavailable targets")
return
var target := get_target_for_task(new_task)
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)
if simulation_manager == null:
push_error("WorldViewManager: simulation_manager missing during arrival report")
return
if simulation_manager.has_method("notify_npc_arrived"):
simulation_manager.notify_npc_arrived(sim_id)
else:
push_error("WorldViewManager: SimulationManager has no notify_npc_arrived method")
func _on_npc_visual_navigation_failed(sim_id: int) -> void:
debug_log("NPC visual navigation failed. sim_id=%s" % sim_id)
if simulation_manager == null:
push_error("WorldViewManager: simulation_manager missing during navigation failure")
return
if simulation_manager.has_method("notify_npc_navigation_failed"):
simulation_manager.notify_npc_navigation_failed(sim_id)
else:
push_error("WorldViewManager: SimulationManager has no notify_npc_navigation_failed method")
func _on_npc_died(npc: SimNPC) -> void:
if not active_npc_visuals.has(npc.id):
return
var visual = active_npc_visuals[npc.id]
if visual.has_method("apply_dead_visual_state"):
visual.apply_dead_visual_state()
else:
push_error("WorldViewManager: NPCVisual has no apply_dead_visual_state method")