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:
@@ -16,6 +16,7 @@ var intelligence: float
|
|||||||
var current_task: StringName = SimulationIds.ACTION_IDLE
|
var current_task: StringName = SimulationIds.ACTION_IDLE
|
||||||
var task_state: StringName = TASK_STATE_IDLE
|
var task_state: StringName = TASK_STATE_IDLE
|
||||||
var position: Vector3
|
var position: Vector3
|
||||||
|
var home_position: Vector3
|
||||||
|
|
||||||
var is_starving := false
|
var is_starving := false
|
||||||
var starvation_ticks := 0
|
var starvation_ticks := 0
|
||||||
@@ -56,6 +57,7 @@ func _init(
|
|||||||
position = Vector3(
|
position = Vector3(
|
||||||
random_source.randf_range(-8.0, 8.0), 0.0, random_source.randf_range(-8.0, 8.0)
|
random_source.randf_range(-8.0, 8.0), 0.0, random_source.randf_range(-8.0, 8.0)
|
||||||
)
|
)
|
||||||
|
home_position = position
|
||||||
|
|
||||||
|
|
||||||
func die_from_starvation() -> void:
|
func die_from_starvation() -> void:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ extends RefCounted
|
|||||||
var tick_interval: float
|
var tick_interval: float
|
||||||
var accumulator := 0.0
|
var accumulator := 0.0
|
||||||
var elapsed_ticks := 0
|
var elapsed_ticks := 0
|
||||||
|
var cycle_duration_seconds := 240.0
|
||||||
|
|
||||||
|
|
||||||
func _init(interval: float = 1.0) -> void:
|
func _init(interval: float = 1.0) -> void:
|
||||||
@@ -23,3 +24,8 @@ func advance(delta: float) -> int:
|
|||||||
func reset() -> void:
|
func reset() -> void:
|
||||||
accumulator = 0.0
|
accumulator = 0.0
|
||||||
elapsed_ticks = 0
|
elapsed_ticks = 0
|
||||||
|
|
||||||
|
|
||||||
|
func time_of_day() -> float:
|
||||||
|
var total_seconds := elapsed_ticks * tick_interval + accumulator
|
||||||
|
return fmod(total_seconds / maxf(cycle_duration_seconds, 1.0), 1.0)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ var village := SimVillage.new()
|
|||||||
var npcs: Array[SimNPC] = []
|
var npcs: Array[SimNPC] = []
|
||||||
@export var tick_interval := 1.2
|
@export var tick_interval := 1.2
|
||||||
@export var simulation_seed: int = 1337
|
@export var simulation_seed: int = 1337
|
||||||
|
@export var cycle_duration_seconds := 240.0
|
||||||
@export var debug_logs := true
|
@export var debug_logs := true
|
||||||
@export var active_world_adapter: Node
|
@export var active_world_adapter: Node
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ func _ready() -> void:
|
|||||||
set_process(false)
|
set_process(false)
|
||||||
return
|
return
|
||||||
clock = SimulationClock.new(tick_interval)
|
clock = SimulationClock.new(tick_interval)
|
||||||
|
clock.cycle_duration_seconds = cycle_duration_seconds
|
||||||
village.debug_logs = debug_logs
|
village.debug_logs = debug_logs
|
||||||
_initialize_storage()
|
_initialize_storage()
|
||||||
village.update_modifiers()
|
village.update_modifiers()
|
||||||
@@ -112,7 +114,7 @@ func simulate_tick() -> void:
|
|||||||
and old_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
|
and old_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
|
||||||
and npc.task_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
|
and npc.task_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
|
||||||
):
|
):
|
||||||
var selection := action_selector.select_action(npc, village)
|
var selection := action_selector.select_action(npc, village, clock.time_of_day())
|
||||||
if selection != null:
|
if selection != null:
|
||||||
latest_decisions[npc.id] = selection
|
latest_decisions[npc.id] = selection
|
||||||
npc_decision_recorded.emit(npc, selection)
|
npc_decision_recorded.emit(npc, selection)
|
||||||
@@ -195,6 +197,11 @@ func simulate_tick() -> void:
|
|||||||
withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0)
|
withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0)
|
||||||
elif completed_task == SimulationIds.ACTION_EAT:
|
elif completed_task == SimulationIds.ACTION_EAT:
|
||||||
consume_npc_food(npc)
|
consume_npc_food(npc)
|
||||||
|
elif completed_task == SimulationIds.ACTION_SLEEP:
|
||||||
|
npc.energy = minf(npc.energy + 40.0, 100.0)
|
||||||
|
npc.position = npc.home_position
|
||||||
|
if debug_logs:
|
||||||
|
print("[SimulationManager] ", npc.npc_name, " completed sleep at home")
|
||||||
elif (
|
elif (
|
||||||
completed_task
|
completed_task
|
||||||
not in [SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_WOOD]
|
not in [SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_WOOD]
|
||||||
@@ -354,14 +361,19 @@ func notify_npc_target_unavailable(npc_id: int) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
||||||
if active_world_adapter == null:
|
|
||||||
push_error("SimulationManager: active_world_adapter is missing")
|
|
||||||
return false
|
|
||||||
for npc in npcs:
|
for npc in npcs:
|
||||||
if npc.id != npc_id:
|
if npc.id != npc_id:
|
||||||
continue
|
continue
|
||||||
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||||
return false
|
return false
|
||||||
|
if npc.current_task == SimulationIds.ACTION_SLEEP:
|
||||||
|
npc.travel_target_position = npc.home_position
|
||||||
|
npc.has_travel_target = true
|
||||||
|
npc_travel_requested.emit(npc, npc.travel_target_position)
|
||||||
|
return true
|
||||||
|
if active_world_adapter == null:
|
||||||
|
push_error("SimulationManager: active_world_adapter is missing")
|
||||||
|
return false
|
||||||
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
|
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
|
||||||
if result.is_empty():
|
if result.is_empty():
|
||||||
notify_npc_target_unavailable(npc.id)
|
notify_npc_target_unavailable(npc.id)
|
||||||
@@ -738,7 +750,8 @@ func create_state_record() -> SimulationStateRecord:
|
|||||||
"clock_accumulator": clock.accumulator,
|
"clock_accumulator": clock.accumulator,
|
||||||
"clock_elapsed_ticks": clock.elapsed_ticks,
|
"clock_elapsed_ticks": clock.elapsed_ticks,
|
||||||
"wander_random_streams": wander_streams,
|
"wander_random_streams": wander_streams,
|
||||||
"next_event_id": next_event_id
|
"next_event_id": next_event_id,
|
||||||
|
"cycle_duration_seconds": clock.cycle_duration_seconds
|
||||||
}
|
}
|
||||||
record.village = VillageStateRecord.capture(village)
|
record.village = VillageStateRecord.capture(village)
|
||||||
for npc in npcs:
|
for npc in npcs:
|
||||||
@@ -782,6 +795,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
clock = SimulationClock.new(tick_interval)
|
clock = SimulationClock.new(tick_interval)
|
||||||
clock.accumulator = float(record.simulation["clock_accumulator"])
|
clock.accumulator = float(record.simulation["clock_accumulator"])
|
||||||
clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"])
|
clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"])
|
||||||
|
clock.cycle_duration_seconds = float(record.simulation.get("cycle_duration_seconds", 240.0))
|
||||||
village = record.village.restore(debug_logs)
|
village = record.village.restore(debug_logs)
|
||||||
storage_states.clear()
|
storage_states.clear()
|
||||||
for storage_record in record.storages:
|
for storage_record in record.storages:
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ func _update_needs(npc: SimNPC, village: SimVillage) -> void:
|
|||||||
SimNPC.TASK_STATE_TRAVELING:
|
SimNPC.TASK_STATE_TRAVELING:
|
||||||
npc.energy -= 2.0
|
npc.energy -= 2.0
|
||||||
SimNPC.TASK_STATE_WORKING:
|
SimNPC.TASK_STATE_WORKING:
|
||||||
if npc.current_task != SimulationIds.ACTION_REST:
|
if npc.current_task == SimulationIds.ACTION_SLEEP:
|
||||||
|
npc.energy = minf(npc.energy + 5.0, 100.0)
|
||||||
|
elif npc.current_task != SimulationIds.ACTION_REST:
|
||||||
npc.energy -= 3.0
|
npc.energy -= 3.0
|
||||||
SimNPC.TASK_STATE_COMPLETE:
|
SimNPC.TASK_STATE_COMPLETE:
|
||||||
npc.energy -= 0.25
|
npc.energy -= 0.25
|
||||||
|
|||||||
@@ -1,10 +1,56 @@
|
|||||||
class_name ActionSelectionSystem
|
class_name ActionSelectionSystem
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
|
enum SchedulePeriod {
|
||||||
|
WORK,
|
||||||
|
SLEEP,
|
||||||
|
MEAL,
|
||||||
|
DISCRETIONARY
|
||||||
|
}
|
||||||
|
|
||||||
func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
|
const SLEEP_BEGIN := 0.88
|
||||||
|
const SLEEP_END := 0.15
|
||||||
|
const BREAKFAST_BEGIN := 0.25
|
||||||
|
const BREAKFAST_END := 0.35
|
||||||
|
const DINNER_BEGIN := 0.7
|
||||||
|
const DINNER_END := 0.8
|
||||||
|
const WORK_BEGIN := 0.28
|
||||||
|
const WORK_END := 0.85
|
||||||
|
|
||||||
|
|
||||||
|
func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5) -> ActionSelectionResult:
|
||||||
if npc.is_dead:
|
if npc.is_dead:
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
var period := _get_period(time_of_day)
|
||||||
|
|
||||||
|
if period == SchedulePeriod.SLEEP:
|
||||||
|
if npc.energy < 60.0:
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_SLEEP, -1.0, "Night-time sleep; energy is low"
|
||||||
|
)
|
||||||
|
if npc.hunger > 75.0 and npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory"
|
||||||
|
)
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home"
|
||||||
|
)
|
||||||
|
|
||||||
|
if period == SchedulePeriod.MEAL:
|
||||||
|
if npc.hunger > 50.0:
|
||||||
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_EAT, -1.0, "Meal-time hunger; eating from inventory"
|
||||||
|
)
|
||||||
|
if village.food > 0:
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Meal-time; pantry has food"
|
||||||
|
)
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Meal-time hunger; pantry is empty"
|
||||||
|
)
|
||||||
|
|
||||||
if npc.is_starving:
|
if npc.is_starving:
|
||||||
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
||||||
return ActionSelectionResult.new(
|
return ActionSelectionResult.new(
|
||||||
@@ -37,6 +83,14 @@ func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
|
|||||||
return ActionSelectionResult.new(
|
return ActionSelectionResult.new(
|
||||||
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
|
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if period == SchedulePeriod.DISCRETIONARY:
|
||||||
|
var roll := npc.random_source.randf()
|
||||||
|
if roll < 0.3:
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander"
|
||||||
|
)
|
||||||
|
|
||||||
return _choose_best_work_action(npc, village)
|
return _choose_best_work_action(npc, village)
|
||||||
|
|
||||||
|
|
||||||
@@ -129,3 +183,23 @@ func _calculate_score(
|
|||||||
if npc.debug_logs:
|
if npc.debug_logs:
|
||||||
print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score)
|
print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score)
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
static func _get_period(time_of_day: float) -> int:
|
||||||
|
if _in_wrap_range(time_of_day, SLEEP_BEGIN, SLEEP_END):
|
||||||
|
return SchedulePeriod.SLEEP
|
||||||
|
if _in_range(time_of_day, BREAKFAST_BEGIN, BREAKFAST_END):
|
||||||
|
return SchedulePeriod.MEAL
|
||||||
|
if _in_range(time_of_day, DINNER_BEGIN, DINNER_END):
|
||||||
|
return SchedulePeriod.MEAL
|
||||||
|
if _in_range(time_of_day, WORK_BEGIN, WORK_END):
|
||||||
|
return SchedulePeriod.WORK
|
||||||
|
return SchedulePeriod.DISCRETIONARY
|
||||||
|
|
||||||
|
|
||||||
|
static func _in_range(value: float, begin: float, end: float) -> bool:
|
||||||
|
return value >= begin and value <= end
|
||||||
|
|
||||||
|
|
||||||
|
static func _in_wrap_range(value: float, begin: float, end: float) -> bool:
|
||||||
|
return value >= begin or value <= end
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const ACTION_PATHS := [
|
|||||||
"res://simulation/definitions/actions/rest.tres",
|
"res://simulation/definitions/actions/rest.tres",
|
||||||
"res://simulation/definitions/actions/wander.tres",
|
"res://simulation/definitions/actions/wander.tres",
|
||||||
"res://simulation/definitions/actions/deposit_food.tres",
|
"res://simulation/definitions/actions/deposit_food.tres",
|
||||||
"res://simulation/definitions/actions/withdraw_food.tres"
|
"res://simulation/definitions/actions/withdraw_food.tres",
|
||||||
|
"res://simulation/definitions/actions/sleep.tres"
|
||||||
]
|
]
|
||||||
|
|
||||||
const PROFESSION_PATHS := [
|
const PROFESSION_PATHS := [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ extends RefCounted
|
|||||||
|
|
||||||
const ACTION_IDLE := &"idle"
|
const ACTION_IDLE := &"idle"
|
||||||
const ACTION_DEAD := &"dead"
|
const ACTION_DEAD := &"dead"
|
||||||
|
const ACTION_SLEEP := &"sleep"
|
||||||
const ACTION_GATHER_FOOD := &"gather_food"
|
const ACTION_GATHER_FOOD := &"gather_food"
|
||||||
const ACTION_GATHER_WOOD := &"gather_wood"
|
const ACTION_GATHER_WOOD := &"gather_wood"
|
||||||
const ACTION_PATROL := &"patrol"
|
const ACTION_PATROL := &"patrol"
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
action_id = &"sleep"
|
||||||
|
display_name = "Sleep"
|
||||||
|
default_duration = 8.0
|
||||||
|
target_type = &"free"
|
||||||
@@ -26,6 +26,7 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
|
|||||||
"current_task": String(npc.current_task),
|
"current_task": String(npc.current_task),
|
||||||
"task_state": String(npc.task_state),
|
"task_state": String(npc.task_state),
|
||||||
"position": [npc.position.x, npc.position.y, npc.position.z],
|
"position": [npc.position.x, npc.position.y, npc.position.z],
|
||||||
|
"home_position": [npc.home_position.x, npc.home_position.y, npc.home_position.z],
|
||||||
"is_starving": npc.is_starving,
|
"is_starving": npc.is_starving,
|
||||||
"starvation_ticks": npc.starvation_ticks,
|
"starvation_ticks": npc.starvation_ticks,
|
||||||
"starvation_death_threshold": npc.starvation_death_threshold,
|
"starvation_death_threshold": npc.starvation_death_threshold,
|
||||||
@@ -137,6 +138,10 @@ func restore(debug_logs: bool) -> SimNPC:
|
|||||||
npc.position = Vector3(
|
npc.position = Vector3(
|
||||||
float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
|
float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
|
||||||
)
|
)
|
||||||
|
var saved_home: Array = data.get("home_position", saved_position)
|
||||||
|
npc.home_position = Vector3(
|
||||||
|
float(saved_home[0]), float(saved_home[1]), float(saved_home[2])
|
||||||
|
)
|
||||||
npc.is_starving = bool(data["is_starving"])
|
npc.is_starving = bool(data["is_starving"])
|
||||||
npc.starvation_ticks = int(data["starvation_ticks"])
|
npc.starvation_ticks = int(data["starvation_ticks"])
|
||||||
npc.starvation_death_threshold = int(data["starvation_death_threshold"])
|
npc.starvation_death_threshold = int(data["starvation_death_threshold"])
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ func _run() -> void:
|
|||||||
manager.debug_logs = false
|
manager.debug_logs = false
|
||||||
root.add_child(manager)
|
root.add_child(manager)
|
||||||
manager.set_process(false)
|
manager.set_process(false)
|
||||||
|
manager.clock.elapsed_ticks = 100
|
||||||
|
|
||||||
var pantry: StorageStateRecord = manager.get_pantry()
|
var pantry: StorageStateRecord = manager.get_pantry()
|
||||||
pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0)
|
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,
|
SimulationDefinitions.get_action(definition.action_id) == definition,
|
||||||
"Action lookup should return '%s'" % definition.action_id
|
"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()
|
var profession_ids := SimulationDefinitions.get_profession_ids()
|
||||||
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
|
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
|
||||||
|
|||||||
Reference in New Issue
Block a user