582 lines
17 KiB
GDScript
582 lines
17 KiB
GDScript
class_name VillageOpportunitySystem
|
|
extends RefCounted
|
|
|
|
const CARE_HUNGER_THRESHOLD := 80.0
|
|
|
|
var opportunities: Array[OpportunityStateRecord] = []
|
|
var next_opportunity_id := 0
|
|
|
|
|
|
func consider_event(
|
|
event: EconomicEventRecord,
|
|
current_tick: int,
|
|
pantry: StorageStateRecord,
|
|
npcs: Array[SimNPC],
|
|
knowledge_system: EventKnowledgeSystem,
|
|
resource_states: Dictionary,
|
|
woodpile: StorageStateRecord = null
|
|
) -> OpportunityStateRecord:
|
|
if event == null or pantry == null or current_tick < 0:
|
|
return null
|
|
var event_tick := int(event.data["tick"])
|
|
if event_tick < 0 or event_tick > current_tick:
|
|
return null
|
|
var resolved := _try_resolve(event, pantry, woodpile, npcs, resource_states)
|
|
if resolved != null:
|
|
return resolved
|
|
var opened := _try_open_food(event, current_tick, pantry, npcs, knowledge_system)
|
|
if opened != null:
|
|
return opened
|
|
if woodpile == null:
|
|
return null
|
|
return _try_open_wood(event, current_tick, woodpile, npcs, knowledge_system)
|
|
|
|
|
|
func maintain_open_opportunity(
|
|
current_tick: int,
|
|
max_age: int,
|
|
npcs: Array[SimNPC],
|
|
pantry: StorageStateRecord,
|
|
woodpile: StorageStateRecord
|
|
) -> OpportunityStateRecord:
|
|
var opportunity := get_open_opportunity()
|
|
if opportunity == null or current_tick < opportunity.get_created_tick():
|
|
return null
|
|
if opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
|
|
return null
|
|
var interested := _find_npc(opportunity.get_interested_npc_id(), npcs)
|
|
if interested == null or interested.is_dead:
|
|
return (
|
|
opportunity
|
|
if opportunity.invalidate(
|
|
SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED, current_tick
|
|
)
|
|
else null
|
|
)
|
|
var target_storage := _get_target_storage(opportunity, pantry, woodpile)
|
|
var condition_is_stale := (
|
|
target_storage == null
|
|
or (
|
|
target_storage.get_amount(opportunity.get_resource_id())
|
|
>= opportunity.get_target_amount()
|
|
)
|
|
or current_tick - opportunity.get_created_tick() >= maxi(max_age, 1)
|
|
)
|
|
if not condition_is_stale:
|
|
return null
|
|
return (
|
|
opportunity
|
|
if opportunity.invalidate(
|
|
SimulationIds.OPPORTUNITY_INVALIDATED_EVIDENCE_STALE, current_tick
|
|
)
|
|
else null
|
|
)
|
|
|
|
|
|
func restore(records: Array[OpportunityStateRecord], restored_next_id: int) -> void:
|
|
opportunities = records.duplicate()
|
|
opportunities.sort_custom(_sort_by_id)
|
|
next_opportunity_id = maxi(restored_next_id, 0)
|
|
|
|
|
|
func get_all_sorted() -> Array[OpportunityStateRecord]:
|
|
var sorted := opportunities.duplicate()
|
|
sorted.sort_custom(_sort_by_id)
|
|
return sorted
|
|
|
|
|
|
func get_open_sorted() -> Array[OpportunityStateRecord]:
|
|
var open_records: Array[OpportunityStateRecord] = []
|
|
for opportunity in opportunities:
|
|
if opportunity.is_open():
|
|
open_records.append(opportunity)
|
|
open_records.sort_custom(_sort_by_id)
|
|
return open_records
|
|
|
|
|
|
func get_open_opportunity() -> OpportunityStateRecord:
|
|
var open_records := get_open_sorted()
|
|
return open_records[0] if not open_records.is_empty() else null
|
|
|
|
|
|
func get_open_trigger_event_id() -> int:
|
|
var opportunity := get_open_opportunity()
|
|
return (
|
|
opportunity.get_trigger_event_id()
|
|
if opportunity != null
|
|
else OpportunityStateRecord.NO_EVENT_ID
|
|
)
|
|
|
|
|
|
func get_latest() -> OpportunityStateRecord:
|
|
var sorted := get_all_sorted()
|
|
return sorted[-1] if not sorted.is_empty() else null
|
|
|
|
|
|
func get_latest_for_npc(npc_id: int) -> OpportunityStateRecord:
|
|
for index in range(opportunities.size() - 1, -1, -1):
|
|
var opportunity := opportunities[index]
|
|
if opportunity.get_interested_npc_id() == npc_id:
|
|
return opportunity
|
|
return null
|
|
|
|
|
|
func find_capable_helper(
|
|
opportunity: OpportunityStateRecord,
|
|
target_storage: StorageStateRecord,
|
|
npcs: Array[SimNPC],
|
|
knowledge_system: EventKnowledgeSystem,
|
|
relationship_system: RefCounted,
|
|
resource_states: Dictionary
|
|
) -> OpportunityHelperResult:
|
|
if (
|
|
opportunity == null
|
|
or not opportunity.is_open()
|
|
or target_storage == null
|
|
or knowledge_system == null
|
|
or relationship_system == null
|
|
):
|
|
return null
|
|
var interested := _find_npc(opportunity.get_interested_npc_id(), npcs)
|
|
if interested == null or interested.is_dead:
|
|
return null
|
|
var remaining := maxf(
|
|
opportunity.get_target_amount() - target_storage.get_amount(opportunity.get_resource_id()),
|
|
0.0
|
|
)
|
|
if remaining <= 0.0 or target_storage.get_available_capacity() < remaining:
|
|
return null
|
|
var action_ids := _get_supply_action_ids(opportunity.get_resource_id())
|
|
if action_ids.is_empty():
|
|
return null
|
|
var gather_definition := SimulationDefinitions.get_action(action_ids["gather"])
|
|
var deposit_definition := SimulationDefinitions.get_action(action_ids["deposit"])
|
|
if gather_definition == null or deposit_definition == null:
|
|
return null
|
|
var candidates: Array[Dictionary] = []
|
|
for npc in npcs:
|
|
var candidate := _build_helper_candidate(
|
|
npc,
|
|
opportunity,
|
|
remaining,
|
|
gather_definition,
|
|
deposit_definition,
|
|
interested.npc_name,
|
|
knowledge_system,
|
|
relationship_system,
|
|
resource_states
|
|
)
|
|
if not candidate.is_empty():
|
|
candidates.append(candidate)
|
|
if candidates.is_empty():
|
|
return null
|
|
candidates.sort_custom(_sort_helper_candidates)
|
|
var best := candidates[0]
|
|
return (
|
|
OpportunityHelperResult
|
|
. new(
|
|
{
|
|
"opportunity_id": opportunity.get_opportunity_id(),
|
|
"helper_npc_id": (best["npc"] as SimNPC).id,
|
|
"action_id": best["action_id"],
|
|
"source_id": best["source_id"],
|
|
"resource_id": opportunity.get_resource_id(),
|
|
"trigger_event_id": opportunity.get_trigger_event_id(),
|
|
"trust": best["trust"],
|
|
"familiarity": best["familiarity"],
|
|
"uses_inventory": best["uses_inventory"],
|
|
"available_source_count": best["available_source_count"],
|
|
"profession_match": best["profession_match"],
|
|
"reason": best["reason"],
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
func find_player_response(
|
|
opportunity: OpportunityStateRecord,
|
|
target_storage: StorageStateRecord,
|
|
resource_states: Dictionary,
|
|
has_capable_helper: bool
|
|
) -> OpportunityPlayerResponseResult:
|
|
if (
|
|
opportunity == null
|
|
or not opportunity.is_open()
|
|
or target_storage == null
|
|
or target_storage.get_storage_id() != opportunity.get_target_id()
|
|
or has_capable_helper
|
|
):
|
|
return null
|
|
var remaining := maxf(
|
|
opportunity.get_target_amount() - target_storage.get_amount(opportunity.get_resource_id()),
|
|
0.0
|
|
)
|
|
if remaining <= 0.0 or target_storage.get_available_capacity() < remaining:
|
|
return null
|
|
var action_ids := _get_supply_action_ids(opportunity.get_resource_id())
|
|
if action_ids.is_empty():
|
|
return null
|
|
var gather_definition := SimulationDefinitions.get_action(action_ids["gather"])
|
|
if gather_definition == null:
|
|
return null
|
|
var available_source_count := _count_player_resource_sources(
|
|
remaining, opportunity.get_resource_id(), gather_definition, resource_states
|
|
)
|
|
if available_source_count == 0:
|
|
return null
|
|
return (
|
|
OpportunityPlayerResponseResult
|
|
. new(
|
|
{
|
|
"opportunity_id": opportunity.get_opportunity_id(),
|
|
"trigger_event_id": opportunity.get_trigger_event_id(),
|
|
"action_id": gather_definition.action_id,
|
|
"resource_id": opportunity.get_resource_id(),
|
|
"target_id": target_storage.get_storage_id(),
|
|
"available_source_count": available_source_count,
|
|
"reason":
|
|
(
|
|
"No capable helper; %d player-usable finite %s source%s can supply %s"
|
|
% [
|
|
available_source_count,
|
|
String(opportunity.get_resource_id()).capitalize(),
|
|
"" if available_source_count == 1 else "s",
|
|
String(target_storage.get_storage_id()),
|
|
]
|
|
),
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
func _build_helper_candidate(
|
|
npc: SimNPC,
|
|
opportunity: OpportunityStateRecord,
|
|
remaining: float,
|
|
gather_definition: ActionDefinition,
|
|
deposit_definition: ActionDefinition,
|
|
interested_name: String,
|
|
knowledge_system: EventKnowledgeSystem,
|
|
relationship_system: RefCounted,
|
|
resource_states: Dictionary
|
|
) -> Dictionary:
|
|
if npc.is_dead or npc.id == opportunity.get_interested_npc_id():
|
|
return {}
|
|
if not knowledge_system.knows_event(npc.id, opportunity.get_trigger_event_id()):
|
|
return {}
|
|
var relationship: RelationshipStateRecord = relationship_system.get_relationship(
|
|
npc.id, opportunity.get_interested_npc_id()
|
|
)
|
|
if (
|
|
relationship == null
|
|
or not relationship_system.is_trusted_for_help(npc.id, opportunity.get_interested_npc_id())
|
|
):
|
|
return {}
|
|
var carried := npc.get_inventory_amount(opportunity.get_resource_id())
|
|
var uses_inventory := carried >= remaining
|
|
var available_source_count := 0
|
|
if not uses_inventory:
|
|
available_source_count = _count_available_resource_sources(
|
|
npc.id, remaining, opportunity.get_resource_id(), gather_definition, resource_states
|
|
)
|
|
if available_source_count == 0:
|
|
return {}
|
|
var action_definition := deposit_definition if uses_inventory else gather_definition
|
|
var source_id := SimulationIds.npc_inventory_id(npc.id) if uses_inventory else &""
|
|
var profession_match := (
|
|
not gather_definition.preferred_profession_id.is_empty()
|
|
and npc.profession == gather_definition.preferred_profession_id
|
|
)
|
|
var supply_fact := (
|
|
"carries %.1f %s" % [carried, String(opportunity.get_resource_id()).capitalize()]
|
|
if uses_inventory
|
|
else (
|
|
"has %d available finite %s source%s"
|
|
% [
|
|
available_source_count,
|
|
String(opportunity.get_resource_id()).capitalize(),
|
|
"" if available_source_count == 1 else "s",
|
|
]
|
|
)
|
|
)
|
|
return {
|
|
"npc": npc,
|
|
"action_id": action_definition.action_id,
|
|
"source_id": source_id,
|
|
"trust": relationship.get_trust(),
|
|
"familiarity": relationship.get_familiarity(),
|
|
"uses_inventory": uses_inventory,
|
|
"available_source_count": available_source_count,
|
|
"profession_match": profession_match,
|
|
"reason":
|
|
(
|
|
"Knows the need; trust %.2f toward %s; %s"
|
|
% [relationship.get_trust(), interested_name, supply_fact]
|
|
),
|
|
}
|
|
|
|
|
|
static func _get_supply_action_ids(resource_id: StringName) -> Dictionary:
|
|
match resource_id:
|
|
SimulationIds.RESOURCE_FOOD:
|
|
return {
|
|
"gather": SimulationIds.ACTION_GATHER_FOOD,
|
|
"deposit": SimulationIds.ACTION_DEPOSIT_FOOD,
|
|
}
|
|
SimulationIds.RESOURCE_WOOD:
|
|
return {
|
|
"gather": SimulationIds.ACTION_GATHER_WOOD,
|
|
"deposit": SimulationIds.ACTION_DEPOSIT_WOOD,
|
|
}
|
|
return {}
|
|
|
|
|
|
static func _count_available_resource_sources(
|
|
npc_id: int,
|
|
remaining: float,
|
|
resource_id: StringName,
|
|
gather_definition: ActionDefinition,
|
|
resource_states: Dictionary
|
|
) -> int:
|
|
var count := 0
|
|
for value in resource_states.values():
|
|
var state := value as ResourceStateRecord
|
|
if (
|
|
state == null
|
|
or state.get_action_id() != gather_definition.resource_action_id
|
|
or state.get_resource_id() != resource_id
|
|
or not state.can_npc_use()
|
|
or not state.is_available_for(npc_id)
|
|
or state.get_yield_per_action() <= 0.0
|
|
or state.get_amount_remaining() < remaining
|
|
):
|
|
continue
|
|
count += 1
|
|
return count
|
|
|
|
|
|
static func _count_player_resource_sources(
|
|
remaining: float,
|
|
resource_id: StringName,
|
|
gather_definition: ActionDefinition,
|
|
resource_states: Dictionary
|
|
) -> int:
|
|
var count := 0
|
|
for value in resource_states.values():
|
|
var state := value as ResourceStateRecord
|
|
if (
|
|
state == null
|
|
or state.get_action_id() != gather_definition.resource_action_id
|
|
or state.get_resource_id() != resource_id
|
|
or not state.can_player_use_resource()
|
|
or not state.can_extract()
|
|
or state.get_yield_per_action() <= 0.0
|
|
or state.get_amount_remaining() < remaining
|
|
):
|
|
continue
|
|
count += 1
|
|
return count
|
|
|
|
|
|
static func _sort_helper_candidates(first: Dictionary, second: Dictionary) -> bool:
|
|
if first["uses_inventory"] != second["uses_inventory"]:
|
|
return bool(first["uses_inventory"])
|
|
if first["trust"] != second["trust"]:
|
|
return float(first["trust"]) > float(second["trust"])
|
|
if first["profession_match"] != second["profession_match"]:
|
|
return bool(first["profession_match"])
|
|
if first["familiarity"] != second["familiarity"]:
|
|
return float(first["familiarity"]) > float(second["familiarity"])
|
|
return (first["npc"] as SimNPC).id < (second["npc"] as SimNPC).id
|
|
|
|
|
|
func _try_open_food(
|
|
event: EconomicEventRecord,
|
|
current_tick: int,
|
|
pantry: StorageStateRecord,
|
|
npcs: Array[SimNPC],
|
|
knowledge_system: EventKnowledgeSystem
|
|
) -> OpportunityStateRecord:
|
|
if knowledge_system == null or get_open_opportunity() != null:
|
|
return null
|
|
if pantry.get_amount(SimulationIds.RESOURCE_FOOD) > 0.0:
|
|
return null
|
|
if not _is_food_withdrawal(event):
|
|
return null
|
|
var interested_npc := _select_interested_npc(
|
|
int(event.data["event_id"]), npcs, knowledge_system
|
|
)
|
|
if interested_npc == null:
|
|
return null
|
|
var opportunity := OpportunityStateRecord.create(
|
|
next_opportunity_id, current_tick, int(event.data["event_id"]), interested_npc.id
|
|
)
|
|
next_opportunity_id += 1
|
|
opportunities.append(opportunity)
|
|
return opportunity
|
|
|
|
|
|
func _try_open_wood(
|
|
event: EconomicEventRecord,
|
|
current_tick: int,
|
|
woodpile: StorageStateRecord,
|
|
npcs: Array[SimNPC],
|
|
knowledge_system: EventKnowledgeSystem
|
|
) -> OpportunityStateRecord:
|
|
if knowledge_system == null or get_open_opportunity() != null:
|
|
return null
|
|
if not _is_missing_wood_block(event):
|
|
return null
|
|
var target_amount := float(event.data.get("required_amount", 0.0))
|
|
if woodpile.get_amount(SimulationIds.RESOURCE_WOOD) >= target_amount:
|
|
return null
|
|
var actor := _find_npc(int(event.data["actor_id"]), npcs)
|
|
if (
|
|
actor == null
|
|
or actor.is_dead
|
|
or not knowledge_system.knows_event(actor.id, int(event.data["event_id"]))
|
|
):
|
|
return null
|
|
var opportunity := OpportunityStateRecord.create(
|
|
next_opportunity_id,
|
|
current_tick,
|
|
int(event.data["event_id"]),
|
|
actor.id,
|
|
target_amount,
|
|
SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD,
|
|
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
|
SimulationIds.RESOURCE_WOOD
|
|
)
|
|
next_opportunity_id += 1
|
|
opportunities.append(opportunity)
|
|
return opportunity
|
|
|
|
|
|
func _try_resolve(
|
|
event: EconomicEventRecord,
|
|
pantry: StorageStateRecord,
|
|
woodpile: StorageStateRecord,
|
|
npcs: Array[SimNPC],
|
|
resource_states: Dictionary
|
|
) -> OpportunityStateRecord:
|
|
var opportunity := get_open_opportunity()
|
|
if opportunity == null:
|
|
return null
|
|
var target_storage := _get_target_storage(opportunity, pantry, woodpile)
|
|
if (
|
|
target_storage == null
|
|
or (
|
|
target_storage.get_amount(opportunity.get_resource_id())
|
|
< opportunity.get_target_amount()
|
|
)
|
|
):
|
|
return null
|
|
if not _is_valid_supply(event, opportunity, npcs, resource_states):
|
|
return null
|
|
if int(event.data["tick"]) < opportunity.get_created_tick():
|
|
return null
|
|
if int(event.data["event_id"]) <= opportunity.get_trigger_event_id():
|
|
return null
|
|
if not opportunity.resolve(int(event.data["event_id"]), int(event.data["tick"])):
|
|
return null
|
|
return opportunity
|
|
|
|
|
|
func _select_interested_npc(
|
|
event_id: int, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem
|
|
) -> SimNPC:
|
|
var candidates: Array[SimNPC] = []
|
|
for npc in npcs:
|
|
if npc.is_dead or npc.hunger < CARE_HUNGER_THRESHOLD:
|
|
continue
|
|
if not knowledge_system.knows_event(npc.id, event_id):
|
|
continue
|
|
candidates.append(npc)
|
|
candidates.sort_custom(_sort_by_care)
|
|
return candidates[0] if not candidates.is_empty() else null
|
|
|
|
|
|
static func _is_food_withdrawal(event: EconomicEventRecord) -> bool:
|
|
var actor_id := int(event.data["actor_id"])
|
|
return (
|
|
StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_WITHDRAWN
|
|
and actor_id >= 0
|
|
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
|
|
and StringName(event.data["destination_id"]) == SimulationIds.npc_inventory_id(actor_id)
|
|
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
|
and float(event.data["amount"]) > 0.0
|
|
)
|
|
|
|
|
|
static func _is_missing_wood_block(event: EconomicEventRecord) -> bool:
|
|
return (
|
|
StringName(event.data["event_type"]) == SimulationIds.EVENT_TASK_BLOCKED
|
|
and int(event.data["actor_id"]) >= 0
|
|
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE
|
|
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_WOOD
|
|
and (
|
|
StringName(event.data.get("action_id", &""))
|
|
in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY]
|
|
)
|
|
and float(event.data.get("required_amount", 0.0)) > 0.0
|
|
)
|
|
|
|
|
|
static func _is_valid_supply(
|
|
event: EconomicEventRecord,
|
|
opportunity: OpportunityStateRecord,
|
|
npcs: Array[SimNPC],
|
|
resource_states: Dictionary
|
|
) -> bool:
|
|
if (
|
|
StringName(event.data["item_id"]) != opportunity.get_resource_id()
|
|
or StringName(event.data["destination_id"]) != opportunity.get_target_id()
|
|
or float(event.data["amount"]) <= 0.0
|
|
):
|
|
return false
|
|
var event_type := StringName(event.data["event_type"])
|
|
var actor_id := int(event.data["actor_id"])
|
|
if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED:
|
|
return (
|
|
_find_npc(actor_id, npcs) != null
|
|
and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id)
|
|
)
|
|
if event_type != SimulationIds.EVENT_RESOURCE_EXTRACTED or actor_id != -1:
|
|
return false
|
|
var resource_state := (
|
|
resource_states.get(StringName(event.data["source_id"])) as ResourceStateRecord
|
|
)
|
|
return (
|
|
resource_state != null
|
|
and resource_state.get_resource_id() == opportunity.get_resource_id()
|
|
and resource_state.can_player_use_resource()
|
|
)
|
|
|
|
|
|
static func _get_target_storage(
|
|
opportunity: OpportunityStateRecord, pantry: StorageStateRecord, woodpile: StorageStateRecord
|
|
) -> StorageStateRecord:
|
|
match opportunity.get_target_id():
|
|
SimulationIds.STORAGE_VILLAGE_PANTRY:
|
|
return pantry
|
|
SimulationIds.STORAGE_VILLAGE_WOODPILE:
|
|
return woodpile
|
|
return null
|
|
|
|
|
|
static func _sort_by_care(first: SimNPC, second: SimNPC) -> bool:
|
|
if first.hunger != second.hunger:
|
|
return first.hunger > second.hunger
|
|
return first.id < second.id
|
|
|
|
|
|
static func _find_npc(npc_id: int, npcs: Array[SimNPC]) -> SimNPC:
|
|
for npc in npcs:
|
|
if npc.id == npc_id:
|
|
return npc
|
|
return null
|
|
|
|
|
|
static func _sort_by_id(first: OpportunityStateRecord, second: OpportunityStateRecord) -> bool:
|
|
return first.get_opportunity_id() < second.get_opportunity_id()
|