8a955f3c29
ProfessionDefinition gains schedule_offset (-0.12 to +0.12 fraction of day). ActionSelectionSystem shifts each NPC's effective time_of_day by their profession offset before determining sleep/meal/work periods. Farmers wake earliest (-0.04h = ~2:38am), woodcutters slightly early (-0.02h), guards slightly late (+0.02h), scholars latest (+0.05h = ~5:45am). Wanderers have no offset. The village now has naturally staggered rhythms instead of everyone doing the same thing at the same time.
247 lines
6.8 KiB
GDScript
247 lines
6.8 KiB
GDScript
extends SceneTree
|
|
|
|
var failures: Array[String] = []
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
_test_sleep_period_at_night()
|
|
_test_work_period_during_day()
|
|
_test_meal_period_hunger()
|
|
_test_sleep_restores_energy()
|
|
_test_schedule_serialization()
|
|
_test_sleep_targets_home()
|
|
_test_household_familiarity()
|
|
|
|
if failures.is_empty():
|
|
print("[TEST] NPC schedule passed")
|
|
quit(0)
|
|
return
|
|
|
|
for failure in failures:
|
|
push_error("[TEST] " + failure)
|
|
quit(1)
|
|
|
|
|
|
func _create_manager(seed_value: int) -> Node:
|
|
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)
|
|
return manager
|
|
|
|
|
|
func _test_sleep_period_at_night() -> void:
|
|
var manager := _create_manager(100)
|
|
manager.clock.cycle_duration_seconds = 240.0
|
|
manager.clock.elapsed_ticks = 0
|
|
var npc: SimNPC = manager.npcs[0]
|
|
npc.hunger = 20.0
|
|
npc.energy = 50.0
|
|
npc.home_position = npc.position
|
|
|
|
var selection := ActionSelectionSystem.new().select_action(
|
|
npc, manager.village, manager.clock.time_of_day()
|
|
)
|
|
_check(selection != null, "Night-time should produce an action selection")
|
|
_check(
|
|
selection.action_id == SimulationIds.ACTION_SLEEP,
|
|
"Low-energy NPC should choose SLEEP during night period"
|
|
)
|
|
_check(
|
|
selection.reason.to_lower().contains("night"),
|
|
"Sleep selection reason should reference night-time"
|
|
)
|
|
manager.free()
|
|
|
|
|
|
func _test_work_period_during_day() -> void:
|
|
var manager := _create_manager(200)
|
|
manager.clock.cycle_duration_seconds = 240.0
|
|
manager.clock.elapsed_ticks = 100
|
|
var time_of_day: float = manager.clock.time_of_day()
|
|
_check(
|
|
time_of_day > 0.28 and time_of_day < 0.85,
|
|
"100 ticks should land in the work period"
|
|
)
|
|
var npc: SimNPC = manager.npcs[0]
|
|
npc.profession = SimulationIds.PROFESSION_WANDERER
|
|
npc.hunger = 20.0
|
|
npc.energy = 80.0
|
|
npc.home_position = npc.position
|
|
|
|
var selection := ActionSelectionSystem.new().select_action(
|
|
npc, manager.village, time_of_day
|
|
)
|
|
_check(selection != null, "Daytime should produce an action selection")
|
|
_check(
|
|
selection.action_id != SimulationIds.ACTION_SLEEP,
|
|
"A well-rested NPC should not choose SLEEP during work period"
|
|
)
|
|
manager.free()
|
|
|
|
|
|
func _test_meal_period_hunger() -> void:
|
|
var manager := _create_manager(300)
|
|
manager.clock.cycle_duration_seconds = 240.0
|
|
manager.clock.elapsed_ticks = 50
|
|
var time_of_day: float = manager.clock.time_of_day()
|
|
_check(
|
|
time_of_day >= 0.25 and time_of_day <= 0.35,
|
|
"50 ticks should land in the breakfast meal period"
|
|
)
|
|
var npc: SimNPC = manager.npcs[0]
|
|
npc.profession = SimulationIds.PROFESSION_WANDERER
|
|
npc.hunger = 65.0
|
|
npc.energy = 80.0
|
|
manager.village.food = 5.0
|
|
npc.home_position = npc.position
|
|
|
|
var selection := ActionSelectionSystem.new().select_action(
|
|
npc, manager.village, time_of_day
|
|
)
|
|
_check(selection != null, "Meal period should produce an action selection")
|
|
_check(
|
|
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
|
|
"Hungry NPC during meal period with pantry food should WITHDRAW_FOOD"
|
|
)
|
|
manager.free()
|
|
|
|
|
|
func _test_sleep_restores_energy() -> void:
|
|
var manager := _create_manager(400)
|
|
manager.clock.cycle_duration_seconds = 240.0
|
|
manager.clock.elapsed_ticks = 0
|
|
var npc: SimNPC = manager.npcs[0]
|
|
npc.hunger = 20.0
|
|
npc.energy = 30.0
|
|
npc.home_position = npc.position
|
|
|
|
npc.set_task(SimulationIds.ACTION_SLEEP, 4.0)
|
|
npc.start_working()
|
|
var energy_before := npc.energy
|
|
|
|
for tick in 4:
|
|
manager.simulate_tick()
|
|
|
|
_check(npc.task_complete, "Sleep should complete after its duration")
|
|
_check(
|
|
npc.energy > energy_before,
|
|
"Completing sleep should restore energy"
|
|
)
|
|
manager.free()
|
|
|
|
|
|
func _test_schedule_serialization() -> void:
|
|
var manager := _create_manager(500)
|
|
manager.clock.cycle_duration_seconds = 180.0
|
|
manager.clock.elapsed_ticks = 30
|
|
var npc: SimNPC = manager.npcs[0]
|
|
npc.home_position = Vector3(3.0, 0.0, 4.0)
|
|
npc.position = Vector3(1.0, 0.0, 2.0)
|
|
|
|
var saved_json: String = manager.serialize_state()
|
|
var restored := _create_manager(1)
|
|
_check(
|
|
restored.restore_state_from_json(saved_json),
|
|
"Schedule state should survive serialization round-trip"
|
|
)
|
|
_check(
|
|
is_equal_approx(restored.clock.cycle_duration_seconds, 180.0),
|
|
"Cycle duration should round-trip through save"
|
|
)
|
|
var restored_npc: SimNPC = restored.npcs[0]
|
|
_check(
|
|
restored_npc.home_position.distance_to(Vector3(3.0, 0.0, 4.0)) < 0.01,
|
|
"NPC home position should round-trip through save"
|
|
)
|
|
manager.free()
|
|
restored.free()
|
|
|
|
|
|
func _test_sleep_targets_home() -> void:
|
|
var manager := _create_manager(600)
|
|
manager.clock.cycle_duration_seconds = 240.0
|
|
manager.clock.elapsed_ticks = 0
|
|
var npc: SimNPC = manager.npcs[0]
|
|
npc.energy = 40.0
|
|
npc.home_position = Vector3(5.0, 0.0, 5.0)
|
|
npc.position = Vector3(0.0, 0.0, 0.0)
|
|
|
|
npc.set_task(SimulationIds.ACTION_SLEEP)
|
|
_check(npc.task_state == SimNPC.TASK_STATE_TRAVELING, "Sleep should put NPC in traveling state")
|
|
|
|
var resolved: bool = manager.resolve_npc_target(npc.id, npc.position)
|
|
_check(resolved, "Sleep target should resolve without an active world adapter")
|
|
_check(
|
|
npc.travel_target_position.distance_to(Vector3(5.0, 0.0, 5.0)) < 0.01,
|
|
"Sleep should target the NPC home position"
|
|
)
|
|
manager.free()
|
|
|
|
|
|
func _test_household_familiarity() -> void:
|
|
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
|
manager.simulation_seed = 700
|
|
manager.debug_logs = false
|
|
manager.set_process(false)
|
|
manager.home_positions = [
|
|
Vector3(-15.0, 2.0, 7.0),
|
|
Vector3(-15.0, 2.0, 8.0),
|
|
Vector3(-8.5, 2.0, 3.0),
|
|
Vector3(-6.5, 2.0, 1.5),
|
|
Vector3(20.0, 0.0, 20.0),
|
|
Vector3(22.0, 0.0, 22.0)
|
|
]
|
|
root.add_child(manager)
|
|
|
|
var a: SimNPC = manager.npcs[0]
|
|
var b: SimNPC = manager.npcs[1]
|
|
var c: SimNPC = manager.npcs[2]
|
|
var d: SimNPC = manager.npcs[3]
|
|
var e: SimNPC = manager.npcs[4]
|
|
|
|
_check(
|
|
a.familiarity.has(b.id) and b.familiarity.has(a.id),
|
|
"NPCs with nearby homes should be mutual household members"
|
|
)
|
|
_check(
|
|
is_equal_approx(float(a.familiarity[b.id]), 0.5),
|
|
"Household familiarity should start at baseline 0.5"
|
|
)
|
|
_check(
|
|
c.familiarity.has(d.id) and d.familiarity.has(c.id),
|
|
"Second household pair should also be familiar"
|
|
)
|
|
_check(
|
|
not a.familiarity.has(e.id),
|
|
"Distant NPCs should not have household familiarity"
|
|
)
|
|
|
|
var saved_json: String = manager.serialize_state()
|
|
var restored: Node = load("res://simulation/SimulationManager.gd").new()
|
|
restored.debug_logs = false
|
|
root.add_child(restored)
|
|
restored.set_process(false)
|
|
_check(
|
|
restored.restore_state_from_json(saved_json),
|
|
"Familiarity should survive save/load round-trip"
|
|
)
|
|
var ra: SimNPC = restored.npcs[0]
|
|
_check(
|
|
ra.familiarity.size() > 0,
|
|
"Restored NPC should retain household familiarity"
|
|
)
|
|
|
|
manager.free()
|
|
restored.free()
|
|
|
|
|
|
func _check(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append(message)
|