diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index eeac751..c5fe5b9 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -16,6 +16,7 @@ var intelligence: float var current_task: StringName = SimulationIds.ACTION_IDLE var task_state: StringName = TASK_STATE_IDLE var position: Vector3 +var home_position: Vector3 var is_starving := false var starvation_ticks := 0 @@ -56,6 +57,7 @@ func _init( position = Vector3( 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: diff --git a/simulation/SimulationClock.gd b/simulation/SimulationClock.gd index 46913e6..c89e2eb 100644 --- a/simulation/SimulationClock.gd +++ b/simulation/SimulationClock.gd @@ -4,6 +4,7 @@ extends RefCounted var tick_interval: float var accumulator := 0.0 var elapsed_ticks := 0 +var cycle_duration_seconds := 240.0 func _init(interval: float = 1.0) -> void: @@ -23,3 +24,8 @@ func advance(delta: float) -> int: func reset() -> void: accumulator = 0.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) diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 24140cb..466aff3 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -15,6 +15,7 @@ var village := SimVillage.new() var npcs: Array[SimNPC] = [] @export var tick_interval := 1.2 @export var simulation_seed: int = 1337 +@export var cycle_duration_seconds := 240.0 @export var debug_logs := true @export var active_world_adapter: Node @@ -42,6 +43,7 @@ func _ready() -> void: set_process(false) return clock = SimulationClock.new(tick_interval) + clock.cycle_duration_seconds = cycle_duration_seconds village.debug_logs = debug_logs _initialize_storage() village.update_modifiers() @@ -112,7 +114,7 @@ func simulate_tick() -> void: 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] ): - var selection := action_selector.select_action(npc, village) + var selection := action_selector.select_action(npc, village, clock.time_of_day()) if selection != null: latest_decisions[npc.id] = selection npc_decision_recorded.emit(npc, selection) @@ -195,6 +197,11 @@ func simulate_tick() -> void: withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0) elif completed_task == SimulationIds.ACTION_EAT: 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 ( completed_task 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: - if active_world_adapter == null: - push_error("SimulationManager: active_world_adapter is missing") - return false for npc in npcs: if npc.id != npc_id: continue if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING: 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) if result.is_empty(): notify_npc_target_unavailable(npc.id) @@ -738,7 +750,8 @@ func create_state_record() -> SimulationStateRecord: "clock_accumulator": clock.accumulator, "clock_elapsed_ticks": clock.elapsed_ticks, "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) for npc in npcs: @@ -782,6 +795,7 @@ func restore_state(record: SimulationStateRecord) -> bool: clock = SimulationClock.new(tick_interval) clock.accumulator = float(record.simulation["clock_accumulator"]) 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) storage_states.clear() for storage_record in record.storages: diff --git a/simulation/actions/ActionExecutionSystem.gd b/simulation/actions/ActionExecutionSystem.gd index f444985..5a0a24a 100644 --- a/simulation/actions/ActionExecutionSystem.gd +++ b/simulation/actions/ActionExecutionSystem.gd @@ -22,7 +22,9 @@ func _update_needs(npc: SimNPC, village: SimVillage) -> void: SimNPC.TASK_STATE_TRAVELING: npc.energy -= 2.0 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 SimNPC.TASK_STATE_COMPLETE: npc.energy -= 0.25 diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index 086f372..6f477e9 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -1,10 +1,56 @@ class_name ActionSelectionSystem 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: 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.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: return ActionSelectionResult.new( @@ -37,6 +83,14 @@ func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult: return ActionSelectionResult.new( 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) @@ -129,3 +183,23 @@ func _calculate_score( if npc.debug_logs: print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", 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 diff --git a/simulation/definitions/SimulationDefinitions.gd b/simulation/definitions/SimulationDefinitions.gd index 7c0f613..1774356 100644 --- a/simulation/definitions/SimulationDefinitions.gd +++ b/simulation/definitions/SimulationDefinitions.gd @@ -10,7 +10,8 @@ const ACTION_PATHS := [ "res://simulation/definitions/actions/rest.tres", "res://simulation/definitions/actions/wander.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 := [ diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 29fc102..a6e7b13 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -3,6 +3,7 @@ extends RefCounted const ACTION_IDLE := &"idle" const ACTION_DEAD := &"dead" +const ACTION_SLEEP := &"sleep" const ACTION_GATHER_FOOD := &"gather_food" const ACTION_GATHER_WOOD := &"gather_wood" const ACTION_PATROL := &"patrol" diff --git a/simulation/definitions/actions/sleep.tres b/simulation/definitions/actions/sleep.tres new file mode 100644 index 0000000..4200c3b --- /dev/null +++ b/simulation/definitions/actions/sleep.tres @@ -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" diff --git a/simulation/state/NPCStateRecord.gd b/simulation/state/NPCStateRecord.gd index bfebc5d..e19c44c 100644 --- a/simulation/state/NPCStateRecord.gd +++ b/simulation/state/NPCStateRecord.gd @@ -26,6 +26,7 @@ static func capture(npc: SimNPC) -> NPCStateRecord: "current_task": String(npc.current_task), "task_state": String(npc.task_state), "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, "starvation_ticks": npc.starvation_ticks, "starvation_death_threshold": npc.starvation_death_threshold, @@ -137,6 +138,10 @@ func restore(debug_logs: bool) -> SimNPC: npc.position = Vector3( 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.starvation_ticks = int(data["starvation_ticks"]) npc.starvation_death_threshold = int(data["starvation_death_threshold"]) diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd index 40dd0d8..a336c7f 100644 --- a/tests/food_storage_loop_test.gd +++ b/tests/food_storage_loop_test.gd @@ -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) diff --git a/tests/npc_schedule_test.gd b/tests/npc_schedule_test.gd new file mode 100644 index 0000000..9a9d271 --- /dev/null +++ b/tests/npc_schedule_test.gd @@ -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) diff --git a/tests/simulation_definitions_test.gd b/tests/simulation_definitions_test.gd index 380f698..73fae30 100644 --- a/tests/simulation_definitions_test.gd +++ b/tests/simulation_definitions_test.gd @@ -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")