37610242bf
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
91 lines
2.7 KiB
GDScript
91 lines
2.7 KiB
GDScript
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
|
|
|
|
var simulation_manager: Node = main_scene.get_node("SimulationManager")
|
|
simulation_manager.set_process(false)
|
|
|
|
_check(simulation_manager.npcs.size() == 3, "Baseline should create three NPCs")
|
|
_check(main_scene.get_node("ResourceNodes").get_child_count() == 4, "Baseline should contain four ResourceNodes")
|
|
|
|
var navigation_map: RID = main_scene.get_world_3d().navigation_map
|
|
await _wait_for_navigation_map(navigation_map)
|
|
NavigationServer3D.map_force_update(navigation_map)
|
|
|
|
var fixed_origins := [
|
|
Vector3(-6.0, 0.0, -4.0),
|
|
Vector3(0.0, 0.0, 0.0),
|
|
Vector3(6.0, 0.0, 4.0)
|
|
]
|
|
var destinations: Array[Vector3] = []
|
|
for marker_path in [
|
|
"ActivityMarkers/FoodZone",
|
|
"ActivityMarkers/GuardZone",
|
|
"ActivityMarkers/StudyZone",
|
|
"ActivityMarkers/RestZone"
|
|
]:
|
|
destinations.append(main_scene.get_node(marker_path).global_position)
|
|
|
|
for resource in main_scene.get_node("ResourceNodes").get_children():
|
|
destinations.append((resource as ResourceNode).interaction_point.global_position)
|
|
|
|
for origin in fixed_origins:
|
|
for destination in destinations:
|
|
var path := NavigationServer3D.map_get_path(
|
|
navigation_map,
|
|
origin,
|
|
destination,
|
|
true
|
|
)
|
|
_check(
|
|
not path.is_empty(),
|
|
"Navigation path should exist from %s to %s" % [origin, destination]
|
|
)
|
|
|
|
var village := SimVillage.new()
|
|
var worker := SimNPC.new(100, "BaselineWorker", "scholar", 5.0, 5.0)
|
|
worker.set_task("study", 2.0)
|
|
worker.start_working()
|
|
worker.simulate_tick(village)
|
|
worker.simulate_tick(village)
|
|
_check(worker.task_complete, "A two-tick working task should complete")
|
|
|
|
var starving := SimNPC.new(101, "BaselineStarving", "wanderer", 5.0, 5.0)
|
|
starving.hunger = 100.0
|
|
starving.starvation_death_threshold = 1
|
|
starving.simulate_tick(village)
|
|
_check(starving.is_dead, "Starvation threshold should still kill an NPC")
|
|
|
|
if failures.is_empty():
|
|
print("[TEST] Flat-map baseline passed: 3 NPCs, 4 resources, 24 navigation routes")
|
|
quit(0)
|
|
return
|
|
|
|
for failure in failures:
|
|
push_error("[TEST] " + failure)
|
|
quit(1)
|
|
|
|
func _wait_for_navigation_map(navigation_map: RID) -> void:
|
|
for attempt in 30:
|
|
if (
|
|
NavigationServer3D.map_get_iteration_id(navigation_map) > 0
|
|
and not NavigationServer3D.map_get_regions(navigation_map).is_empty()
|
|
):
|
|
await physics_frame
|
|
return
|
|
await physics_frame
|
|
_check(false, "Navigation map did not synchronize within 30 physics frames")
|
|
|
|
func _check(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append(message)
|