feat: add simulation action definitions
This commit is contained in:
@@ -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
|
||||
|
||||
+54
-56
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1utbqqel3pyw
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cimqtsrs3huq0
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://v1sohi6bbuga
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://brf7tywgsj6v7
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4iri1xd4ts4
|
||||
@@ -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
|
||||
|
||||
+39
-20
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user