From f816f3976afbcd5a4943ef8d9a16c91f437b91a4 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 5 Jul 2026 13:35:33 +0200 Subject: [PATCH] feat: add simulation action definitions --- player/npc/NpcVisual.gd | 2 +- simulation/SimNPC.gd | 110 +++++++------- simulation/SimVillage.gd | 14 +- simulation/SimulationManager.gd | 50 +++++-- simulation/definitions/ActionDefinition.gd | 30 ++++ .../definitions/ActionDefinition.gd.uid | 1 + .../definitions/ProfessionDefinition.gd | 13 ++ .../definitions/ProfessionDefinition.gd.uid | 1 + .../definitions/SimulationDefinitions.gd | 97 +++++++++++++ .../definitions/SimulationDefinitions.gd.uid | 1 + simulation/definitions/SimulationIds.gd | 25 ++++ simulation/definitions/SimulationIds.gd.uid | 1 + simulation/definitions/actions/eat.tres | 10 ++ .../definitions/actions/gather_food.tres | 12 ++ .../definitions/actions/gather_wood.tres | 12 ++ simulation/definitions/actions/patrol.tres | 11 ++ simulation/definitions/actions/rest.tres | 10 ++ simulation/definitions/actions/study.tres | 11 ++ simulation/definitions/actions/wander.tres | 10 ++ .../definitions/professions/farmer.tres | 8 ++ simulation/definitions/professions/guard.tres | 8 ++ .../definitions/professions/scholar.tres | 8 ++ .../definitions/professions/wanderer.tres | 8 ++ .../definitions/professions/woodcutter.tres | 8 ++ simulation/state/NPCStateRecord.gd | 34 +++-- simulation/state/ResourceStateRecord.gd | 6 + tests/flat_map_baseline_test.gd | 18 ++- tests/jajce_world_scaffold_test.gd | 2 +- tests/simulation_definitions_test.gd | 136 ++++++++++++++++++ tests/simulation_definitions_test.gd.uid | 1 + world/resource_nodes/ResourceNode.gd | 4 +- world/world_view_manager.gd | 59 +++++--- 32 files changed, 608 insertions(+), 113 deletions(-) create mode 100644 simulation/definitions/ActionDefinition.gd create mode 100644 simulation/definitions/ActionDefinition.gd.uid create mode 100644 simulation/definitions/ProfessionDefinition.gd create mode 100644 simulation/definitions/ProfessionDefinition.gd.uid create mode 100644 simulation/definitions/SimulationDefinitions.gd create mode 100644 simulation/definitions/SimulationDefinitions.gd.uid create mode 100644 simulation/definitions/SimulationIds.gd create mode 100644 simulation/definitions/SimulationIds.gd.uid create mode 100644 simulation/definitions/actions/eat.tres create mode 100644 simulation/definitions/actions/gather_food.tres create mode 100644 simulation/definitions/actions/gather_wood.tres create mode 100644 simulation/definitions/actions/patrol.tres create mode 100644 simulation/definitions/actions/rest.tres create mode 100644 simulation/definitions/actions/study.tres create mode 100644 simulation/definitions/actions/wander.tres create mode 100644 simulation/definitions/professions/farmer.tres create mode 100644 simulation/definitions/professions/guard.tres create mode 100644 simulation/definitions/professions/scholar.tres create mode 100644 simulation/definitions/professions/wanderer.tres create mode 100644 simulation/definitions/professions/woodcutter.tres create mode 100644 tests/simulation_definitions_test.gd create mode 100644 tests/simulation_definitions_test.gd.uid diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 9dbd792..ad404ba 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -15,7 +15,7 @@ const DEBUG_LOGS := true var has_reported_arrival := false var sim_id: int = -1 var npc_name: String = "" -var profession: String = "" +var profession: StringName var current_path: PackedVector3Array = [] var path_index := 0 diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index 0bcceac..095f221 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -1,20 +1,20 @@ class_name SimNPC extends RefCounted -const TASK_STATE_IDLE := "idle" -const TASK_STATE_TRAVELING := "traveling" -const TASK_STATE_WORKING := "working" -const TASK_STATE_COMPLETE := "complete" +const TASK_STATE_IDLE := &"idle" +const TASK_STATE_TRAVELING := &"traveling" +const TASK_STATE_WORKING := &"working" +const TASK_STATE_COMPLETE := &"complete" var id: int var npc_name: String -var profession: String +var profession: StringName var hunger: float var energy: float var strength: float var intelligence: float -var current_task: String = "idle" -var task_state: String = TASK_STATE_IDLE +var current_task: StringName = SimulationIds.ACTION_IDLE +var task_state: StringName = TASK_STATE_IDLE var position: Vector3 var is_starving := false @@ -26,14 +26,14 @@ var task_duration := 0.0 var task_progress := 0.0 var task_complete := true var target_id: StringName = &"" -var last_task := "" +var last_task: StringName var random_source: RandomNumberGenerator var debug_logs := true func _init( _id: int, _npc_name: String, - _profession: String, + _profession: StringName, _strength: float, _intelligence: float, _random_source: RandomNumberGenerator = null @@ -87,7 +87,7 @@ func update_needs(village: SimVillage) -> void: TASK_STATE_TRAVELING: energy -= 2.0 TASK_STATE_WORKING: - if current_task != "rest": + if current_task != SimulationIds.ACTION_REST: energy -= 3.0 TASK_STATE_COMPLETE: energy -= 0.25 @@ -107,7 +107,7 @@ func update_needs(village: SimVillage) -> void: func die_from_starvation() -> void: is_dead = true - current_task = "dead" + current_task = SimulationIds.ACTION_DEAD task_state = TASK_STATE_IDLE task_progress = 0.0 task_duration = 0.0 @@ -122,110 +122,97 @@ func choose_new_task(village: SimVillage) -> void: if is_starving: if village.food > 0: - set_task("eat", 1.0) + set_task(SimulationIds.ACTION_EAT, 1.0) else: - set_task("gather_food", 4.0) + set_task(SimulationIds.ACTION_GATHER_FOOD, 4.0) return if hunger > 75.0: if village.food > 0: - set_task("eat", 2.0) + set_task(SimulationIds.ACTION_EAT) else: - set_task("gather_food", 5.0) + set_task(SimulationIds.ACTION_GATHER_FOOD) return if energy < 25.0: - set_task("rest", 4.0) + set_task(SimulationIds.ACTION_REST) return - var best_task := choose_best_work_task(village) - - match best_task: - "gather_food": - set_task("gather_food", 5.0) - "gather_wood": - set_task("gather_wood", 5.0) - "patrol": - set_task("patrol", 6.0) - "study": - set_task("study", 6.0) - _: - set_task("wander", 4.0) + set_task(choose_best_work_task(village)) func should_eat_immediately_after_task() -> bool: - return current_task == "gather_food" and is_starving and not is_dead + return ( + current_task == SimulationIds.ACTION_GATHER_FOOD + and is_starving + and not is_dead + ) -func choose_best_work_task(village: SimVillage) -> String: +func choose_best_work_task(village: SimVillage) -> StringName: var food_score := calculate_task_score( - "gather_food", + SimulationIds.ACTION_GATHER_FOOD, village.food_priority, village.food, 15.0, 30.0, 3.0, - 1.5, - "farmer" + 1.5 ) var wood_score := calculate_task_score( - "gather_wood", + SimulationIds.ACTION_GATHER_WOOD, village.wood_priority, village.wood, 10.0, 20.0, 2.5, - 1.0, - "woodcutter" + 1.0 ) var safety_score := calculate_task_score( - "patrol", + SimulationIds.ACTION_PATROL, village.safety_priority, village.safety, 30.0, 50.0, 3.0, - 1.5, - "guard" + 1.5 ) var knowledge_score := calculate_task_score( - "study", + SimulationIds.ACTION_STUDY, village.knowledge_priority, village.knowledge, 10.0, 25.0, 1.5, - 0.75, - "scholar" + 0.75 ) - var best_task := "gather_food" + var best_task := SimulationIds.ACTION_GATHER_FOOD var best_score := food_score if wood_score > best_score: - best_task = "gather_wood" + best_task = SimulationIds.ACTION_GATHER_WOOD best_score = wood_score if safety_score > best_score: - best_task = "patrol" + best_task = SimulationIds.ACTION_PATROL best_score = safety_score if knowledge_score > best_score: - best_task = "study" + best_task = SimulationIds.ACTION_STUDY best_score = knowledge_score return best_task func calculate_task_score( - task_name: String, + action_id: StringName, base_priority: float, resource_amount: float, critical_threshold: float, low_threshold: float, critical_bonus: float, - low_bonus: float, - preferred_profession: String + low_bonus: float ) -> float: var score := base_priority @@ -234,10 +221,15 @@ func calculate_task_score( elif resource_amount < low_threshold: score += low_bonus - if profession == preferred_profession: + var definition := SimulationDefinitions.get_action(action_id) + if ( + definition != null + and not definition.preferred_profession_id.is_empty() + and profession == definition.preferred_profession_id + ): score += 2.0 - if task_name == last_task: + if action_id == last_task: score -= 1.5 score += random_source.randf_range(0.0, 0.5) @@ -247,16 +239,22 @@ func calculate_task_score( "[SimNPC] ", npc_name, " score ", - task_name, + action_id, " = ", score ) return score -func set_task(task: String, duration: float) -> void: - current_task = task - task_duration = duration +func set_task(action_id: StringName, duration: float = -1.0) -> void: + var definition := SimulationDefinitions.get_action(action_id) + if definition == null: + push_error("SimNPC: unknown action_id '%s'" % action_id) + return + current_task = action_id + task_duration = ( + duration if duration >= 0.0 else definition.default_duration + ) task_progress = 0.0 task_complete = false task_state = TASK_STATE_TRAVELING diff --git a/simulation/SimVillage.gd b/simulation/SimVillage.gd index e8f1a9d..5cbd1a9 100644 --- a/simulation/SimVillage.gd +++ b/simulation/SimVillage.gd @@ -101,15 +101,15 @@ func apply_npc_task(npc: SimNPC) -> void: productivity = 0.5 match npc.current_task: - "gather_food": + SimulationIds.ACTION_GATHER_FOOD: food += 2.0 * productivity - "gather_wood": + SimulationIds.ACTION_GATHER_WOOD: wood += 2.0 * productivity - "patrol": + SimulationIds.ACTION_PATROL: safety += 1.0 * productivity - "study": + SimulationIds.ACTION_STUDY: knowledge += 1.0 * productivity - "eat": + SimulationIds.ACTION_EAT: if food > 0: food -= 1.0 npc.hunger = max(npc.hunger - 55.0, 0.0) @@ -118,14 +118,14 @@ func apply_npc_task(npc: SimNPC) -> void: else: npc.hunger = min(npc.hunger + 5.0, 100.0) npc.is_starving = npc.hunger >= 90.0 - "rest": + SimulationIds.ACTION_REST: if npc.is_starving: npc.energy = min(npc.energy + 2.0, 100.0) elif npc.hunger > 75.0: npc.energy = min(npc.energy + 12.0, 100.0) else: npc.energy = min(npc.energy + 35.0, 100.0) - "wander": + SimulationIds.ACTION_WANDER: safety -= 0.2 food = max(food, 0.0) diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 90a7e84..8cf53d0 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -1,6 +1,10 @@ extends Node -signal npc_task_changed(npc: SimNPC, old_task: String, new_task: String) +signal npc_task_changed( + npc: SimNPC, + old_task: StringName, + new_task: StringName +) signal village_changed(village: SimVillage) signal npc_died(npc: SimNPC) @@ -20,16 +24,14 @@ var names := [ "Amina", "Tarik", "Jasmin" ] -var professions := [ - "farmer", - "woodcutter", - "guard", - "scholar", - "wanderer" -] - func _ready() -> void: add_to_group("simulation_manager") + var definition_errors := SimulationDefinitions.validate() + if not definition_errors.is_empty(): + for error in definition_errors: + push_error("SimulationManager: " + error) + set_process(false) + return clock = SimulationClock.new(tick_interval) village.debug_logs = debug_logs village.update_modifiers() @@ -45,13 +47,17 @@ func _process(delta: float) -> void: simulate_tick() func generate_npcs() -> void: + var profession_ids := SimulationDefinitions.get_profession_ids() for i in range(names.size()): var npc_random := _create_random_source(i, 0) - var profession_index := npc_random.randi_range(0, professions.size() - 1) + var profession_index := npc_random.randi_range( + 0, + profession_ids.size() - 1 + ) var npc := SimNPC.new( i, names[i], - professions[profession_index], + profession_ids[profession_index], npc_random.randf_range(1.0, 10.0), npc_random.randf_range(1.0, 10.0), npc_random @@ -138,7 +144,10 @@ func simulate_tick() -> void: elif debug_logs: print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": target reservation was invalid") release_npc_reservation(npc.id) - elif completed_task not in ["gather_food", "gather_wood"]: + elif completed_task not in [ + SimulationIds.ACTION_GATHER_FOOD, + SimulationIds.ACTION_GATHER_WOOD + ]: village.apply_npc_task(npc) elif debug_logs: print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": no resource target") @@ -148,10 +157,10 @@ func simulate_tick() -> void: npc.task_complete = true npc.task_state = SimNPC.TASK_STATE_IDLE npc.last_task = completed_task - npc.current_task = "idle" + npc.current_task = SimulationIds.ACTION_IDLE if needs_immediate_food_after_gather and village.food > 0: - npc.set_task("eat", 1.0) + npc.set_task(SimulationIds.ACTION_EAT, 1.0) npc_task_changed.emit(npc, completed_task, npc.current_task) if debug_logs: @@ -244,7 +253,7 @@ func notify_npc_navigation_failed(npc_id: int) -> void: var failed_task := npc.current_task release_npc_reservation(npc.id) npc.last_task = failed_task - npc.set_task("wander", 2.0) + npc.set_task(SimulationIds.ACTION_WANDER, 2.0) npc_task_changed.emit(npc, failed_task, npc.current_task) if debug_logs: @@ -261,6 +270,17 @@ func register_loaded_resource_nodes() -> void: func register_resource_node(node: ResourceNode) -> bool: if node == null or node.node_id.is_empty(): return false + var action_definition := SimulationDefinitions.get_action(node.action_id) + if ( + action_definition == null + or action_definition.target_type != SimulationIds.TARGET_RESOURCE + or action_definition.resource_action_id != node.action_id + ): + push_error( + "SimulationManager: ResourceNode '%s' has invalid action_id '%s'" + % [node.node_id, node.action_id] + ) + return false var resource_state := get_resource_state(node.node_id) if resource_state == null: resource_state = ResourceStateRecord.create_from_node(node) diff --git a/simulation/definitions/ActionDefinition.gd b/simulation/definitions/ActionDefinition.gd new file mode 100644 index 0000000..79317a5 --- /dev/null +++ b/simulation/definitions/ActionDefinition.gd @@ -0,0 +1,30 @@ +class_name ActionDefinition +extends Resource + +@export var action_id: StringName +@export var display_name: String +@export_range(0.0, 1000.0, 0.1) var default_duration := 1.0 +@export var preferred_profession_id: StringName +@export var target_type: StringName = SimulationIds.TARGET_ACTIVITY +@export var resource_action_id: StringName + +func validate() -> Array[String]: + var errors: Array[String] = [] + if action_id.is_empty(): + errors.append("action_id is empty") + if display_name.is_empty(): + errors.append("display_name is empty for '%s'" % action_id) + if default_duration <= 0.0: + errors.append("default_duration must be positive for '%s'" % action_id) + if target_type not in [ + SimulationIds.TARGET_RESOURCE, + SimulationIds.TARGET_ACTIVITY, + SimulationIds.TARGET_FREE + ]: + errors.append("target_type '%s' is invalid for '%s'" % [target_type, action_id]) + if ( + target_type == SimulationIds.TARGET_RESOURCE + and resource_action_id.is_empty() + ): + errors.append("resource_action_id is required for '%s'" % action_id) + return errors diff --git a/simulation/definitions/ActionDefinition.gd.uid b/simulation/definitions/ActionDefinition.gd.uid new file mode 100644 index 0000000..2a1ad1d --- /dev/null +++ b/simulation/definitions/ActionDefinition.gd.uid @@ -0,0 +1 @@ +uid://b1utbqqel3pyw diff --git a/simulation/definitions/ProfessionDefinition.gd b/simulation/definitions/ProfessionDefinition.gd new file mode 100644 index 0000000..a7ec45e --- /dev/null +++ b/simulation/definitions/ProfessionDefinition.gd @@ -0,0 +1,13 @@ +class_name ProfessionDefinition +extends Resource + +@export var profession_id: StringName +@export var display_name: String + +func validate() -> Array[String]: + var errors: Array[String] = [] + if profession_id.is_empty(): + errors.append("profession_id is empty") + if display_name.is_empty(): + errors.append("display_name is empty for '%s'" % profession_id) + return errors diff --git a/simulation/definitions/ProfessionDefinition.gd.uid b/simulation/definitions/ProfessionDefinition.gd.uid new file mode 100644 index 0000000..5467882 --- /dev/null +++ b/simulation/definitions/ProfessionDefinition.gd.uid @@ -0,0 +1 @@ +uid://cimqtsrs3huq0 diff --git a/simulation/definitions/SimulationDefinitions.gd b/simulation/definitions/SimulationDefinitions.gd new file mode 100644 index 0000000..bdb3bae --- /dev/null +++ b/simulation/definitions/SimulationDefinitions.gd @@ -0,0 +1,97 @@ +class_name SimulationDefinitions +extends RefCounted + +const ACTION_PATHS := [ + "res://simulation/definitions/actions/gather_food.tres", + "res://simulation/definitions/actions/gather_wood.tres", + "res://simulation/definitions/actions/patrol.tres", + "res://simulation/definitions/actions/study.tres", + "res://simulation/definitions/actions/eat.tres", + "res://simulation/definitions/actions/rest.tres", + "res://simulation/definitions/actions/wander.tres" +] + +const PROFESSION_PATHS := [ + "res://simulation/definitions/professions/farmer.tres", + "res://simulation/definitions/professions/woodcutter.tres", + "res://simulation/definitions/professions/guard.tres", + "res://simulation/definitions/professions/scholar.tres", + "res://simulation/definitions/professions/wanderer.tres" +] + +static func get_actions() -> Array[ActionDefinition]: + var definitions: Array[ActionDefinition] = [] + for path in ACTION_PATHS: + var definition := load(path) as ActionDefinition + if definition != null: + definitions.append(definition) + return definitions + +static func get_professions() -> Array[ProfessionDefinition]: + var definitions: Array[ProfessionDefinition] = [] + for path in PROFESSION_PATHS: + var definition := load(path) as ProfessionDefinition + if definition != null: + definitions.append(definition) + return definitions + +static func get_action(action_id: StringName) -> ActionDefinition: + for definition in get_actions(): + if definition.action_id == action_id: + return definition + return null + +static func get_profession( + profession_id: StringName +) -> ProfessionDefinition: + for definition in get_professions(): + if definition.profession_id == profession_id: + return definition + return null + +static func get_profession_ids() -> Array[StringName]: + var ids: Array[StringName] = [] + for definition in get_professions(): + ids.append(definition.profession_id) + return ids + +static func validate() -> Array[String]: + var errors: Array[String] = [] + if get_actions().size() != ACTION_PATHS.size(): + errors.append("One or more ActionDefinition resources failed to load") + if get_professions().size() != PROFESSION_PATHS.size(): + errors.append("One or more ProfessionDefinition resources failed to load") + var action_ids := {} + for definition in get_actions(): + for error in definition.validate(): + errors.append("ActionDefinition: " + error) + if action_ids.has(definition.action_id): + errors.append("Duplicate action_id '%s'" % definition.action_id) + action_ids[definition.action_id] = true + + var profession_ids := {} + for definition in get_professions(): + for error in definition.validate(): + errors.append("ProfessionDefinition: " + error) + if profession_ids.has(definition.profession_id): + errors.append("Duplicate profession_id '%s'" % definition.profession_id) + profession_ids[definition.profession_id] = true + + for definition in get_actions(): + if ( + not definition.preferred_profession_id.is_empty() + and not profession_ids.has(definition.preferred_profession_id) + ): + errors.append( + "Action '%s' references unknown profession '%s'" + % [definition.action_id, definition.preferred_profession_id] + ) + if ( + definition.target_type == SimulationIds.TARGET_RESOURCE + and not action_ids.has(definition.resource_action_id) + ): + errors.append( + "Action '%s' references unknown resource action '%s'" + % [definition.action_id, definition.resource_action_id] + ) + return errors diff --git a/simulation/definitions/SimulationDefinitions.gd.uid b/simulation/definitions/SimulationDefinitions.gd.uid new file mode 100644 index 0000000..eadbb99 --- /dev/null +++ b/simulation/definitions/SimulationDefinitions.gd.uid @@ -0,0 +1 @@ +uid://v1sohi6bbuga diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd new file mode 100644 index 0000000..d4baa6e --- /dev/null +++ b/simulation/definitions/SimulationIds.gd @@ -0,0 +1,25 @@ +class_name SimulationIds +extends RefCounted + +const ACTION_IDLE := &"idle" +const ACTION_DEAD := &"dead" +const ACTION_GATHER_FOOD := &"gather_food" +const ACTION_GATHER_WOOD := &"gather_wood" +const ACTION_PATROL := &"patrol" +const ACTION_STUDY := &"study" +const ACTION_EAT := &"eat" +const ACTION_REST := &"rest" +const ACTION_WANDER := &"wander" + +const PROFESSION_FARMER := &"farmer" +const PROFESSION_WOODCUTTER := &"woodcutter" +const PROFESSION_GUARD := &"guard" +const PROFESSION_SCHOLAR := &"scholar" +const PROFESSION_WANDERER := &"wanderer" + +const TARGET_RESOURCE := &"resource" +const TARGET_ACTIVITY := &"activity" +const TARGET_FREE := &"free" + +const RESOURCE_FOOD := &"food" +const RESOURCE_WOOD := &"wood" diff --git a/simulation/definitions/SimulationIds.gd.uid b/simulation/definitions/SimulationIds.gd.uid new file mode 100644 index 0000000..8a2d0fe --- /dev/null +++ b/simulation/definitions/SimulationIds.gd.uid @@ -0,0 +1 @@ +uid://brf7tywgsj6v7 diff --git a/simulation/definitions/actions/eat.tres b/simulation/definitions/actions/eat.tres new file mode 100644 index 0000000..6e48a3e --- /dev/null +++ b/simulation/definitions/actions/eat.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 = &"eat" +display_name = "Eat" +default_duration = 2.0 +target_type = &"activity" diff --git a/simulation/definitions/actions/gather_food.tres b/simulation/definitions/actions/gather_food.tres new file mode 100644 index 0000000..ab520e1 --- /dev/null +++ b/simulation/definitions/actions/gather_food.tres @@ -0,0 +1,12 @@ +[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 = &"gather_food" +display_name = "Gather Food" +default_duration = 5.0 +preferred_profession_id = &"farmer" +target_type = &"resource" +resource_action_id = &"gather_food" diff --git a/simulation/definitions/actions/gather_wood.tres b/simulation/definitions/actions/gather_wood.tres new file mode 100644 index 0000000..d4e6a8a --- /dev/null +++ b/simulation/definitions/actions/gather_wood.tres @@ -0,0 +1,12 @@ +[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 = &"gather_wood" +display_name = "Gather Wood" +default_duration = 5.0 +preferred_profession_id = &"woodcutter" +target_type = &"resource" +resource_action_id = &"gather_wood" diff --git a/simulation/definitions/actions/patrol.tres b/simulation/definitions/actions/patrol.tres new file mode 100644 index 0000000..6ae498c --- /dev/null +++ b/simulation/definitions/actions/patrol.tres @@ -0,0 +1,11 @@ +[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 = &"patrol" +display_name = "Patrol" +default_duration = 6.0 +preferred_profession_id = &"guard" +target_type = &"activity" diff --git a/simulation/definitions/actions/rest.tres b/simulation/definitions/actions/rest.tres new file mode 100644 index 0000000..c682f99 --- /dev/null +++ b/simulation/definitions/actions/rest.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 = &"rest" +display_name = "Rest" +default_duration = 4.0 +target_type = &"activity" diff --git a/simulation/definitions/actions/study.tres b/simulation/definitions/actions/study.tres new file mode 100644 index 0000000..3dbf537 --- /dev/null +++ b/simulation/definitions/actions/study.tres @@ -0,0 +1,11 @@ +[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 = &"study" +display_name = "Study" +default_duration = 6.0 +preferred_profession_id = &"scholar" +target_type = &"activity" diff --git a/simulation/definitions/actions/wander.tres b/simulation/definitions/actions/wander.tres new file mode 100644 index 0000000..30d5254 --- /dev/null +++ b/simulation/definitions/actions/wander.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 = &"wander" +display_name = "Wander" +default_duration = 4.0 +target_type = &"free" diff --git a/simulation/definitions/professions/farmer.tres b/simulation/definitions/professions/farmer.tres new file mode 100644 index 0000000..65653e5 --- /dev/null +++ b/simulation/definitions/professions/farmer.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +profession_id = &"farmer" +display_name = "Farmer" diff --git a/simulation/definitions/professions/guard.tres b/simulation/definitions/professions/guard.tres new file mode 100644 index 0000000..001467a --- /dev/null +++ b/simulation/definitions/professions/guard.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +profession_id = &"guard" +display_name = "Guard" diff --git a/simulation/definitions/professions/scholar.tres b/simulation/definitions/professions/scholar.tres new file mode 100644 index 0000000..36a2a43 --- /dev/null +++ b/simulation/definitions/professions/scholar.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +profession_id = &"scholar" +display_name = "Scholar" diff --git a/simulation/definitions/professions/wanderer.tres b/simulation/definitions/professions/wanderer.tres new file mode 100644 index 0000000..a17094e --- /dev/null +++ b/simulation/definitions/professions/wanderer.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +profession_id = &"wanderer" +display_name = "Wanderer" diff --git a/simulation/definitions/professions/woodcutter.tres b/simulation/definitions/professions/woodcutter.tres new file mode 100644 index 0000000..fa91ce1 --- /dev/null +++ b/simulation/definitions/professions/woodcutter.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +profession_id = &"woodcutter" +display_name = "Woodcutter" diff --git a/simulation/state/NPCStateRecord.gd b/simulation/state/NPCStateRecord.gd index dc991e6..8cf7019 100644 --- a/simulation/state/NPCStateRecord.gd +++ b/simulation/state/NPCStateRecord.gd @@ -13,13 +13,13 @@ static func capture(npc: SimNPC) -> NPCStateRecord: "schema_version": SCHEMA_VERSION, "id": npc.id, "name": npc.npc_name, - "profession": npc.profession, + "profession": String(npc.profession), "hunger": npc.hunger, "energy": npc.energy, "strength": npc.strength, "intelligence": npc.intelligence, - "current_task": npc.current_task, - "task_state": npc.task_state, + "current_task": String(npc.current_task), + "task_state": String(npc.task_state), "position": [npc.position.x, npc.position.y, npc.position.z], "is_starving": npc.is_starving, "starvation_ticks": npc.starvation_ticks, @@ -29,7 +29,7 @@ static func capture(npc: SimNPC) -> NPCStateRecord: "task_progress": npc.task_progress, "task_complete": npc.task_complete, "target_id": String(npc.target_id), - "last_task": npc.last_task, + "last_task": String(npc.last_task), "random_seed": str(npc.random_source.seed), "random_state": str(npc.random_source.state) }) @@ -48,6 +48,24 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord: var saved_position = record_data["position"] if not saved_position is Array or saved_position.size() != 3: return null + var profession_id := StringName(record_data["profession"]) + if SimulationDefinitions.get_profession(profession_id) == null: + return null + var action_id := StringName(record_data["current_task"]) + if ( + action_id not in [ + SimulationIds.ACTION_IDLE, + SimulationIds.ACTION_DEAD + ] + and SimulationDefinitions.get_action(action_id) == null + ): + return null + var last_action_id := StringName(record_data["last_task"]) + if ( + not last_action_id.is_empty() + and SimulationDefinitions.get_action(last_action_id) == null + ): + return null return NPCStateRecord.new(record_data) func restore(debug_logs: bool) -> SimNPC: @@ -56,7 +74,7 @@ func restore(debug_logs: bool) -> SimNPC: var npc := SimNPC.new( int(data["id"]), String(data["name"]), - String(data["profession"]), + StringName(data["profession"]), float(data["strength"]), float(data["intelligence"]), random_source @@ -64,8 +82,8 @@ func restore(debug_logs: bool) -> SimNPC: var saved_position: Array = data["position"] npc.hunger = float(data["hunger"]) npc.energy = float(data["energy"]) - npc.current_task = String(data["current_task"]) - npc.task_state = String(data["task_state"]) + npc.current_task = StringName(data["current_task"]) + npc.task_state = StringName(data["task_state"]) npc.position = Vector3( float(saved_position[0]), float(saved_position[1]), @@ -79,7 +97,7 @@ func restore(debug_logs: bool) -> SimNPC: npc.task_progress = float(data["task_progress"]) npc.task_complete = bool(data["task_complete"]) npc.target_id = StringName(data["target_id"]) - npc.last_task = String(data["last_task"]) + npc.last_task = StringName(data["last_task"]) npc.random_source.state = String(data["random_state"]).to_int() npc.debug_logs = debug_logs return npc diff --git a/simulation/state/ResourceStateRecord.gd b/simulation/state/ResourceStateRecord.gd index 09046a2..cc3998e 100644 --- a/simulation/state/ResourceStateRecord.gd +++ b/simulation/state/ResourceStateRecord.gd @@ -41,6 +41,12 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord: return null if String(record_data["node_id"]).is_empty(): return null + var action_id := StringName(record_data["action_id"]) + if ( + not action_id.is_empty() + and SimulationDefinitions.get_action(action_id) == null + ): + return null return ResourceStateRecord.new(record_data) static func _migrate_v1(legacy_data: Dictionary) -> Dictionary: diff --git a/tests/flat_map_baseline_test.gd b/tests/flat_map_baseline_test.gd index c525540..b31a243 100644 --- a/tests/flat_map_baseline_test.gd +++ b/tests/flat_map_baseline_test.gd @@ -52,14 +52,26 @@ func _run() -> void: ) var village := SimVillage.new() - var worker := SimNPC.new(100, "BaselineWorker", "scholar", 5.0, 5.0) - worker.set_task("study", 2.0) + var worker := SimNPC.new( + 100, + "BaselineWorker", + SimulationIds.PROFESSION_SCHOLAR, + 5.0, + 5.0 + ) + worker.set_task(SimulationIds.ACTION_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) + var starving := SimNPC.new( + 101, + "BaselineStarving", + SimulationIds.PROFESSION_WANDERER, + 5.0, + 5.0 + ) starving.hunger = 100.0 starving.starvation_death_threshold = 1 starving.simulate_tick(village) diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index eaa1d44..6a92daf 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -93,7 +93,7 @@ func _run() -> void: manager.set_process(false) await process_frame var selected: ResourceNode = manager.acquire_resource_target( - &"gather_food", + SimulationIds.ACTION_GATHER_FOOD, 777, Vector3.ZERO ) diff --git a/tests/simulation_definitions_test.gd b/tests/simulation_definitions_test.gd new file mode 100644 index 0000000..f878b56 --- /dev/null +++ b/tests/simulation_definitions_test.gd @@ -0,0 +1,136 @@ +extends SceneTree + +var failures: Array[String] = [] + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _test_registry_integrity() + _test_definition_backed_behavior() + _test_state_reference_validation() + _test_id_round_trip() + + if failures.is_empty(): + print("[TEST] Simulation definitions passed") + quit(0) + return + + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + +func _test_registry_integrity() -> void: + var errors := SimulationDefinitions.validate() + _check( + errors.is_empty(), + "Definition registry should validate: %s" % [errors] + ) + + var action_ids: Array[StringName] = [] + for definition in SimulationDefinitions.get_actions(): + action_ids.append(definition.action_id) + _check( + SimulationDefinitions.get_action(definition.action_id) == definition, + "Action lookup should return '%s'" % definition.action_id + ) + _check( + action_ids.size() == 7, + "Registry should contain all seven executable prototype actions" + ) + + var profession_ids := SimulationDefinitions.get_profession_ids() + _check( + profession_ids.size() == 5, + "Registry should contain all five prototype professions" + ) + for profession_id in profession_ids: + _check( + SimulationDefinitions.get_profession(profession_id) != null, + "Profession lookup should return '%s'" % profession_id + ) + +func _test_definition_backed_behavior() -> void: + var npc := SimNPC.new( + 500, + "DefinitionWorker", + SimulationIds.PROFESSION_SCHOLAR, + 5.0, + 5.0 + ) + npc.set_task(SimulationIds.ACTION_STUDY) + var study := SimulationDefinitions.get_action(SimulationIds.ACTION_STUDY) + _check( + is_equal_approx(npc.task_duration, study.default_duration), + "SimNPC should use the action definition's default duration" + ) + _check( + study.preferred_profession_id == SimulationIds.PROFESSION_SCHOLAR, + "Study should reference the stable scholar profession ID" + ) + var gather := SimulationDefinitions.get_action( + SimulationIds.ACTION_GATHER_FOOD + ) + _check( + gather.target_type == SimulationIds.TARGET_RESOURCE + and gather.resource_action_id == SimulationIds.ACTION_GATHER_FOOD, + "Gather-food target metadata should come from its definition" + ) + +func _test_state_reference_validation() -> void: + var npc := SimNPC.new( + 501, + "ValidationWorker", + SimulationIds.PROFESSION_FARMER, + 5.0, + 5.0 + ) + var valid_data := NPCStateRecord.capture(npc).to_dictionary() + var invalid_profession := valid_data.duplicate(true) + invalid_profession["profession"] = "missing_profession" + _check( + NPCStateRecord.from_dictionary(invalid_profession) == null, + "NPC state should reject an unknown profession ID" + ) + var invalid_action := valid_data.duplicate(true) + invalid_action["current_task"] = "missing_action" + _check( + NPCStateRecord.from_dictionary(invalid_action) == null, + "NPC state should reject an unknown action ID" + ) + +func _test_id_round_trip() -> void: + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.debug_logs = false + root.add_child(manager) + manager.set_process(false) + var json_state: 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(json_state), + "Definition-backed IDs should restore from serialized state" + ) + for npc in restored.npcs: + _check( + typeof(npc.profession) == TYPE_STRING_NAME, + "Profession should restore as StringName" + ) + _check( + typeof(npc.current_task) == TYPE_STRING_NAME, + "Current action should restore as StringName" + ) + _check( + SimulationDefinitions.get_profession(npc.profession) != null, + "Restored profession should resolve through the registry" + ) + + manager.free() + restored.free() + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/simulation_definitions_test.gd.uid b/tests/simulation_definitions_test.gd.uid new file mode 100644 index 0000000..25c340f --- /dev/null +++ b/tests/simulation_definitions_test.gd.uid @@ -0,0 +1 @@ +uid://c4iri1xd4ts4 diff --git a/world/resource_nodes/ResourceNode.gd b/world/resource_nodes/ResourceNode.gd index 8dceeb4..4177ea6 100644 --- a/world/resource_nodes/ResourceNode.gd +++ b/world/resource_nodes/ResourceNode.gd @@ -8,8 +8,8 @@ signal reservation_changed(node_id: StringName, agent_id: int) static var _all: Array[ResourceNode] = [] @export var node_id: StringName -@export var action_id: StringName = &"gather_food" -@export var resource_id: StringName = &"food" +@export var action_id: StringName = SimulationIds.ACTION_GATHER_FOOD +@export var resource_id: StringName = SimulationIds.RESOURCE_FOOD @export var initial_amount: float = 10.0 @export var yield_per_action: float = 2.0 @export var initial_enabled: bool = true diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index ccd0400..9353aed 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -87,7 +87,11 @@ func update_npc_targets() -> void: var visual = active_npc_visuals[npc.id] var task = npc.current_task - if task in ["", "idle", "dead"]: + if task in [ + &"", + SimulationIds.ACTION_IDLE, + SimulationIds.ACTION_DEAD + ]: continue var resource_pos = _try_get_resource_target(npc, npc.current_task) @@ -95,12 +99,16 @@ func update_npc_targets() -> void: visual.set_target_position(resource_pos) continue - if task == "wander": + if task == SimulationIds.ACTION_WANDER: var offset := _get_wander_offset(npc.id) visual.set_target_position(visual.global_position + offset) continue - if task in ["gather_food", "gather_wood"]: + var definition := SimulationDefinitions.get_action(task) + if ( + definition != null + and definition.target_type == SimulationIds.TARGET_RESOURCE + ): if simulation_manager.has_method("notify_npc_target_unavailable"): simulation_manager.notify_npc_target_unavailable(npc.id) continue @@ -113,26 +121,25 @@ func update_npc_targets() -> void: visual.set_target_position(target.global_position) -func get_target_for_task(task: String) -> Marker3D: +func get_target_for_task(task: StringName) -> Marker3D: match task: - "patrol": + SimulationIds.ACTION_PATROL: return guard_zone - "study": + SimulationIds.ACTION_STUDY: return study_zone - "rest": + SimulationIds.ACTION_REST: return rest_zone - "eat": + SimulationIds.ACTION_EAT: return food_zone _: return rest_zone -func _try_get_resource_target(npc: SimNPC, task: String) -> Variant: - var action_map := { - "gather_food": &"gather_food", - "gather_wood": &"gather_wood" - } - var action_id = action_map.get(task) - if action_id == null: +func _try_get_resource_target(npc: SimNPC, task: StringName) -> Variant: + var definition := SimulationDefinitions.get_action(task) + if ( + definition == null + or definition.target_type != SimulationIds.TARGET_RESOURCE + ): return null var visual = active_npc_visuals.get(npc.id) as Node3D @@ -143,7 +150,7 @@ func _try_get_resource_target(npc: SimNPC, task: String) -> Variant: push_error("WorldViewManager: SimulationManager cannot acquire resources") return null var node: ResourceNode = simulation_manager.acquire_resource_target( - action_id, + definition.resource_action_id, npc.id, visual.global_position ) @@ -155,14 +162,22 @@ func _try_get_resource_target(npc: SimNPC, task: String) -> Variant: debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task]) return node.interaction_point.global_position -func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> void: +func _on_npc_task_changed( + npc: SimNPC, + old_task: StringName, + new_task: StringName +) -> void: if DEBUG_LOGS: debug_log("%s changed task: %s -> %s" % [npc.npc_name, old_task, new_task]) if not active_npc_visuals.has(npc.id): return - if new_task in ["", "idle", "dead"]: + if new_task in [ + &"", + SimulationIds.ACTION_IDLE, + SimulationIds.ACTION_DEAD + ]: return var visual = active_npc_visuals[npc.id] @@ -171,12 +186,16 @@ func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> vo visual.set_target_position(resource_pos) return - if new_task == "wander": + if new_task == SimulationIds.ACTION_WANDER: var offset := _get_wander_offset(npc.id) visual.set_target_position(visual.global_position + offset) return - if new_task in ["gather_food", "gather_wood"]: + var definition := SimulationDefinitions.get_action(new_task) + if ( + definition != null + and definition.target_type == SimulationIds.TARGET_RESOURCE + ): if simulation_manager.has_method("notify_npc_target_unavailable"): simulation_manager.notify_npc_target_unavailable(npc.id) else: