Files
gamedev-the-steward/simulation/actions/ActionSelectionSystem.gd
T
2026-07-30 23:03:15 +02:00

318 lines
9.5 KiB
GDScript

class_name ActionSelectionSystem
extends RefCounted
enum SchedulePeriod { WORK, SLEEP, MEAL, DISCRETIONARY }
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
const UNAVAILABLE_ACTION_SCORE := -1000000.0
const RELATIONSHIP_AID_PANTRY_THRESHOLD := 25.0
var relationship_system: RefCounted
func select_action(
npc: SimNPC,
village: SimVillage,
time_of_day: float = 0.5,
all_npcs: Array = [],
opportunity_helper: OpportunityHelperResult = null,
population_view: SimulationPopulationView = null,
animal_feed_available: bool = false
) -> ActionSelectionResult:
if npc.is_dead:
return null
if npc.is_starving:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, 1.0, "Starving and carrying food"
)
if village.food > 0:
return ActionSelectionResult.new(
SimulationIds.ACTION_WITHDRAW_FOOD, 1.0, "Starving; pantry has food"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Starving; must find food"
)
if npc.mourning_ticks > 0:
if npc.energy < 40.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Mourning; seeking rest"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_WANDER, -1.0, "Mourning; wandering near home"
)
if npc.hunger > 80.0:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, -1.0, "Critically hungry and carrying food"
)
if village.food > 0:
return ActionSelectionResult.new(
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Critically hungry; pantry has food"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Critically hungry; must find food"
)
var profession_def := SimulationDefinitions.get_profession(npc.profession)
var offset: float = profession_def.schedule_offset if profession_def != null else 0.0
var local_time := fmod(time_of_day + offset + 1.0, 1.0)
var period := _get_period(local_time)
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.hunger > 75.0:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, -1.0, "Hungry and carrying food"
)
if village.food > 0:
return ActionSelectionResult.new(
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Hungry; pantry has food"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Hungry; pantry is empty"
)
var is_opportunity_helper := _is_current_opportunity_helper(npc, opportunity_helper)
if is_opportunity_helper and npc.energy < 25.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Energy is low; resting before helping"
)
if is_opportunity_helper:
return ActionSelectionResult.new(
opportunity_helper.action_id,
-1.0,
"Responding to village need: %s" % opportunity_helper.reason
)
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage"
)
if npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD) > 0.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_DEPOSIT_WOOD, -1.0, "Carrying wood for storage"
)
if npc.energy < 25.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
)
if animal_feed_available:
var feed_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
if _get_cost_rejection(feed_definition, village).is_empty():
return ActionSelectionResult.new(
SimulationIds.ACTION_FEED_ANIMAL,
-1.0,
"A hungry animal is available; pantry food is available"
)
if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null:
var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject(
npc.id, all_npcs, population_view
)
if trusted_starving_npc != null:
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD,
-1.0,
"Helping %s, a trusted acquaintance who is starving" % trusted_starving_npc.npc_name
)
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)
func _is_current_opportunity_helper(
npc: SimNPC, opportunity_helper: OpportunityHelperResult
) -> bool:
return (
opportunity_helper != null
and opportunity_helper.helper_npc_id == npc.id
and (
opportunity_helper.action_id
in [
SimulationIds.ACTION_GATHER_FOOD,
SimulationIds.ACTION_GATHER_WOOD,
SimulationIds.ACTION_DEPOSIT_FOOD,
SimulationIds.ACTION_DEPOSIT_WOOD,
]
)
)
func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
var scores := {
SimulationIds.ACTION_GATHER_FOOD:
_calculate_score(
npc,
SimulationIds.ACTION_GATHER_FOOD,
village.food_priority,
village.food,
15.0,
30.0,
3.0,
1.5
),
SimulationIds.ACTION_GATHER_WOOD:
_calculate_score(
npc,
SimulationIds.ACTION_GATHER_WOOD,
village.wood_priority,
village.wood,
10.0,
20.0,
2.5,
1.0
),
SimulationIds.ACTION_PATROL:
_calculate_score(
npc,
SimulationIds.ACTION_PATROL,
village.safety_priority,
village.safety,
30.0,
50.0,
3.0,
1.5
),
SimulationIds.ACTION_STUDY:
_calculate_score(
npc,
SimulationIds.ACTION_STUDY,
village.knowledge_priority,
village.knowledge,
10.0,
25.0,
1.5,
0.75
)
}
var rejections := {}
for action_id in scores:
var definition := SimulationDefinitions.get_action(action_id)
var rejection := _get_cost_rejection(definition, village)
if not rejection.is_empty():
scores[action_id] = UNAVAILABLE_ACTION_SCORE
rejections[action_id] = rejection
var best_action := SimulationIds.ACTION_GATHER_FOOD
var best_score: float = scores[best_action]
for action_id in [
SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY
]:
var score: float = scores[action_id]
if score > best_score:
best_action = action_id
best_score = score
return ActionSelectionResult.new(
best_action, -1.0, "Highest current work utility", scores, rejections
)
func _get_cost_rejection(definition: ActionDefinition, village: SimVillage) -> String:
if definition == null or not definition.has_completion_cost():
return ""
var available := _get_village_resource_amount(village, definition.completion_cost_resource_id)
if available >= definition.completion_cost_amount:
return ""
return (
"Needs %.0f %s (%.1f available)"
% [
definition.completion_cost_amount,
String(definition.completion_cost_resource_id).capitalize(),
available,
]
)
func _get_village_resource_amount(village: SimVillage, resource_id: StringName) -> float:
match resource_id:
SimulationIds.RESOURCE_FOOD:
return village.food
SimulationIds.RESOURCE_WOOD:
return village.wood
return 0.0
func _calculate_score(
npc: SimNPC,
action_id: StringName,
base_priority: float,
resource_amount: float,
critical_threshold: float,
low_threshold: float,
critical_bonus: float,
low_bonus: float
) -> float:
var score := base_priority
if resource_amount < critical_threshold:
score += critical_bonus
elif resource_amount < low_threshold:
score += low_bonus
var definition := SimulationDefinitions.get_action(action_id)
if (
definition != null
and not definition.preferred_profession_id.is_empty()
and npc.profession == definition.preferred_profession_id
):
score += 2.0
if action_id == npc.last_task:
score -= 1.5
score += npc.random_source.randf_range(0.0, 0.5)
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