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
This commit is contained in:
2026-07-05 11:23:12 +02:00
parent b50fae671a
commit 37610242bf
10 changed files with 212 additions and 100 deletions
+8 -8
View File
@@ -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)