feat: add NPC schedules with sleep, meal, and work periods
Add ACTION_SLEEP with home-position targeting and energy restoration. Embed schedule periods (SLEEP/MEAL/WORK/DISCRETIONARY) in ActionSelectionSystem with TimeOfDay from SimulationClock. NPCs sleep at night when energy is low, eat during meal periods when hungry, and work/discretionary the rest of the cycle. Home position stored per NPC and serialized through NPCStateRecord with backward-compatible fallback. Cycle duration persisted through save/restore. Includes headless test for sleep period, work period, meal hunger, energy restoration, serialization round-trip, and home targeting without world adapter.
This commit is contained in:
@@ -12,6 +12,7 @@ func _run() -> void:
|
||||
manager.debug_logs = false
|
||||
root.add_child(manager)
|
||||
manager.set_process(false)
|
||||
manager.clock.elapsed_ticks = 100
|
||||
|
||||
var pantry: StorageStateRecord = manager.get_pantry()
|
||||
pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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()
|
||||
|
||||
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.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.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 _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
@@ -34,7 +34,7 @@ func _test_registry_integrity() -> void:
|
||||
SimulationDefinitions.get_action(definition.action_id) == definition,
|
||||
"Action lookup should return '%s'" % definition.action_id
|
||||
)
|
||||
_check(action_ids.size() == 9, "Registry should contain all nine executable prototype actions")
|
||||
_check(action_ids.size() == 10, "Registry should contain all ten executable prototype actions")
|
||||
|
||||
var profession_ids := SimulationDefinitions.get_profession_ids()
|
||||
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
|
||||
|
||||
Reference in New Issue
Block a user