c61d286005
Wood now follows the same gather-carry-deposit pattern as food. NPCs put harvested wood into inventory, walk to the VillageWoodpile StorageNode, and deposit it. Village wood syncs from storage. Player harvesting also routes wood to the woodpile. ActiveWorldAdapter exposes woodpile routing for DEPOSIT_WOOD action targeting. WorldViewManager recognizes wood inventory changes. Tests verify woodpile node existence, storage ID, navigation reachability, and adapter routing.
892 lines
26 KiB
GDScript
892 lines
26 KiB
GDScript
extends Node
|
|
|
|
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
|
|
signal village_changed(village: SimVillage)
|
|
signal npc_died(npc: SimNPC)
|
|
signal npc_target_requested(npc: SimNPC)
|
|
signal npc_travel_requested(npc: SimNPC, target_position: Vector3)
|
|
signal npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float)
|
|
signal economic_event_recorded(event: EconomicEventRecord)
|
|
signal state_restored
|
|
signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult)
|
|
|
|
var village := SimVillage.new()
|
|
|
|
var npcs: Array[SimNPC] = []
|
|
@export var tick_interval := 1.2
|
|
@export var simulation_seed: int = 1337
|
|
@export var cycle_duration_seconds := 240.0
|
|
@export var debug_logs := true
|
|
@export var active_world_adapter: Node
|
|
@export var home_positions: Array[Vector3] = []
|
|
|
|
var clock: SimulationClock
|
|
var tick_count := 0
|
|
var wander_random_sources := {}
|
|
var resource_states: Dictionary = {}
|
|
var storage_states: Dictionary = {}
|
|
var economic_events: Array[EconomicEventRecord] = []
|
|
var next_event_id := 0
|
|
var latest_decisions: Dictionary = {}
|
|
var action_selector := ActionSelectionSystem.new()
|
|
var action_executor := ActionExecutionSystem.new()
|
|
var target_resolver := ActionTargetResolver.new()
|
|
|
|
var names := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"]
|
|
|
|
|
|
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)
|
|
clock.cycle_duration_seconds = cycle_duration_seconds
|
|
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
|
|
village.debug_logs = debug_logs
|
|
_initialize_storage()
|
|
village.update_modifiers()
|
|
village.update_priorities()
|
|
generate_npcs()
|
|
call_deferred("register_loaded_resource_nodes")
|
|
call_deferred("register_loaded_storage_nodes")
|
|
if debug_logs:
|
|
print("--- Simulation started ---")
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
var ticks_due := clock.advance(delta)
|
|
for tick in ticks_due:
|
|
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, profession_ids.size() - 1)
|
|
var npc := SimNPC.new(
|
|
i,
|
|
names[i],
|
|
profession_ids[profession_index],
|
|
npc_random.randf_range(1.0, 10.0),
|
|
npc_random.randf_range(1.0, 10.0),
|
|
npc_random
|
|
)
|
|
|
|
npc.debug_logs = debug_logs
|
|
npcs.append(npc)
|
|
wander_random_sources[i] = _create_random_source(i, 1)
|
|
|
|
var home_count := home_positions.size()
|
|
if home_count > 0:
|
|
for i in range(npcs.size()):
|
|
npcs[i].home_position = home_positions[i % home_count]
|
|
|
|
|
|
func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator:
|
|
var source := RandomNumberGenerator.new()
|
|
source.seed = simulation_seed + (npc_id + 1) * 1000003 + stream_id * 7919
|
|
return source
|
|
|
|
|
|
func get_wander_offset(npc_id: int) -> Vector3:
|
|
var source: RandomNumberGenerator = wander_random_sources.get(npc_id)
|
|
if source == null:
|
|
source = _create_random_source(npc_id, 1)
|
|
wander_random_sources[npc_id] = source
|
|
return Vector3(source.randf_range(-6.0, 6.0), 0.0, source.randf_range(-6.0, 6.0))
|
|
|
|
|
|
func simulate_tick() -> void:
|
|
tick_count += 1
|
|
if debug_logs:
|
|
print("--- Tick ", tick_count, " ---")
|
|
|
|
var village_was_changed := false
|
|
|
|
for npc in npcs:
|
|
var old_task := npc.current_task
|
|
var old_target := npc.target_id
|
|
var old_state := npc.task_state
|
|
var was_complete := npc.task_complete
|
|
var was_dead := npc.is_dead
|
|
|
|
action_executor.advance_npc(npc, village)
|
|
if (
|
|
not npc.is_dead
|
|
and old_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
|
|
and npc.task_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
|
|
):
|
|
var selection := action_selector.select_action(npc, village, clock.time_of_day())
|
|
if selection != null:
|
|
latest_decisions[npc.id] = selection
|
|
npc_decision_recorded.emit(npc, selection)
|
|
npc.set_task(selection.action_id, selection.duration_override)
|
|
|
|
if old_task != npc.current_task and old_target != &"":
|
|
release_npc_reservation(npc.id)
|
|
|
|
if not was_dead and npc.is_dead:
|
|
if old_target != &"":
|
|
release_npc_reservation(npc.id)
|
|
npc_died.emit(npc)
|
|
npc_task_changed.emit(npc, old_task, npc.current_task)
|
|
|
|
if debug_logs:
|
|
print("[SimulationManager] NPC died: ", npc.npc_name)
|
|
|
|
if old_task != npc.current_task and not npc.is_dead:
|
|
npc_task_changed.emit(npc, old_task, npc.current_task)
|
|
npc_target_requested.emit(npc)
|
|
|
|
if not was_complete and npc.task_complete and not npc.is_dead:
|
|
var completed_task := npc.current_task
|
|
var completed_definition := SimulationDefinitions.get_action(completed_task)
|
|
if (
|
|
npc.target_id != &""
|
|
and completed_definition != null
|
|
and completed_definition.target_type == SimulationIds.TARGET_RESOURCE
|
|
):
|
|
var resource_state := get_resource_state(npc.target_id)
|
|
if resource_state != null and resource_state.get_reserved_by() == npc.id:
|
|
var extracted := resource_state.extract()
|
|
if extracted > 0:
|
|
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD:
|
|
npc.add_inventory(SimulationIds.RESOURCE_FOOD, extracted)
|
|
npc_inventory_changed.emit(
|
|
npc,
|
|
SimulationIds.RESOURCE_FOOD,
|
|
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
|
|
)
|
|
elif resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD:
|
|
npc.add_inventory(SimulationIds.RESOURCE_WOOD, extracted)
|
|
npc_inventory_changed.emit(
|
|
npc,
|
|
SimulationIds.RESOURCE_WOOD,
|
|
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
|
|
)
|
|
else:
|
|
village.apply_resource_delta(
|
|
resource_state.get_resource_id(), extracted
|
|
)
|
|
_record_economic_event(
|
|
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
|
npc.id,
|
|
resource_state.get_node_id(),
|
|
(
|
|
_npc_inventory_id(npc.id)
|
|
if resource_state.get_resource_id() in [
|
|
SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD
|
|
]
|
|
else &"village"
|
|
),
|
|
resource_state.get_resource_id(),
|
|
extracted
|
|
)
|
|
if debug_logs:
|
|
print(
|
|
"[SimulationManager] ",
|
|
npc.npc_name,
|
|
" extracted ",
|
|
extracted,
|
|
" ",
|
|
resource_state.get_resource_id(),
|
|
" from ",
|
|
resource_state.get_node_id()
|
|
)
|
|
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 == SimulationIds.ACTION_DEPOSIT_FOOD:
|
|
deposit_npc_inventory(npc, SimulationIds.RESOURCE_FOOD)
|
|
elif completed_task == SimulationIds.ACTION_DEPOSIT_WOOD:
|
|
deposit_npc_wood(npc)
|
|
elif completed_task == SimulationIds.ACTION_WITHDRAW_FOOD:
|
|
withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0)
|
|
elif completed_task == SimulationIds.ACTION_EAT:
|
|
consume_npc_food(npc)
|
|
elif completed_task == SimulationIds.ACTION_SLEEP:
|
|
npc.energy = minf(npc.energy + 40.0, 100.0)
|
|
npc.position = npc.home_position
|
|
if debug_logs:
|
|
print("[SimulationManager] ", npc.npc_name, " completed sleep at home")
|
|
elif (
|
|
completed_task
|
|
not in [SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_WOOD]
|
|
):
|
|
village.apply_npc_task(npc)
|
|
elif debug_logs:
|
|
print(
|
|
"[SimulationManager] ",
|
|
npc.npc_name,
|
|
" could not complete ",
|
|
completed_task,
|
|
": no resource target"
|
|
)
|
|
|
|
village_was_changed = true
|
|
|
|
npc.task_complete = true
|
|
npc.task_state = SimNPC.TASK_STATE_IDLE
|
|
npc.last_task = completed_task
|
|
npc.current_task = SimulationIds.ACTION_IDLE
|
|
npc.has_travel_target = false
|
|
npc.target_id = &""
|
|
npc_task_changed.emit(npc, completed_task, npc.current_task)
|
|
|
|
if debug_logs:
|
|
print(
|
|
npc.npc_name,
|
|
" | ",
|
|
npc.profession,
|
|
" | task: ",
|
|
npc.current_task,
|
|
" | state: ",
|
|
npc.task_state,
|
|
" | progress: ",
|
|
npc.task_progress,
|
|
"/",
|
|
npc.task_duration,
|
|
" | complete: ",
|
|
npc.task_complete,
|
|
" | hunger: ",
|
|
round(npc.hunger),
|
|
" | energy: ",
|
|
round(npc.energy),
|
|
" | starving: ",
|
|
npc.is_starving,
|
|
" | starvation_ticks: ",
|
|
npc.starvation_ticks,
|
|
" | dead: ",
|
|
npc.is_dead
|
|
)
|
|
|
|
if old_state != npc.task_state:
|
|
print(
|
|
"[SimulationManager] State changed: ",
|
|
npc.npc_name,
|
|
" ",
|
|
old_state,
|
|
" -> ",
|
|
npc.task_state
|
|
)
|
|
|
|
if village_was_changed:
|
|
village_changed.emit(village)
|
|
|
|
if debug_logs:
|
|
print(village.get_summary())
|
|
|
|
|
|
func set_npc_target_id(npc_id: int, target_id: StringName) -> void:
|
|
for npc in npcs:
|
|
if npc.id == npc_id:
|
|
npc.target_id = target_id
|
|
return
|
|
|
|
|
|
func release_npc_reservation(npc_id: int) -> void:
|
|
for npc in npcs:
|
|
if npc.id == npc_id:
|
|
if npc.target_id != &"":
|
|
var resource_state := get_resource_state(npc.target_id)
|
|
if resource_state != null:
|
|
resource_state.release(npc.id)
|
|
npc.target_id = &""
|
|
return
|
|
|
|
|
|
func notify_npc_arrived(npc_id: int) -> void:
|
|
for npc in npcs:
|
|
if npc.id == npc_id:
|
|
if npc.is_dead:
|
|
return
|
|
|
|
if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
|
|
var definition := SimulationDefinitions.get_action(npc.current_task)
|
|
if (
|
|
npc.target_id != &""
|
|
and definition != null
|
|
and definition.target_type == SimulationIds.TARGET_RESOURCE
|
|
):
|
|
var resource_state := get_resource_state(npc.target_id)
|
|
if (
|
|
resource_state == null
|
|
or not resource_state.can_extract()
|
|
or resource_state.get_reserved_by() != npc.id
|
|
):
|
|
if debug_logs:
|
|
print(
|
|
"[SimulationManager] ",
|
|
npc.npc_name,
|
|
" arrived but target ",
|
|
npc.target_id,
|
|
" is unavailable, replanning"
|
|
)
|
|
notify_npc_navigation_failed(npc.id)
|
|
return
|
|
|
|
npc.has_travel_target = false
|
|
npc.start_working()
|
|
|
|
if debug_logs:
|
|
print(
|
|
"[SimulationManager] ",
|
|
npc.npc_name,
|
|
" arrived and started working on: ",
|
|
npc.current_task
|
|
)
|
|
return
|
|
|
|
|
|
func notify_npc_navigation_failed(npc_id: int) -> void:
|
|
for npc in npcs:
|
|
if npc.id != npc_id:
|
|
continue
|
|
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
|
return
|
|
|
|
var failed_task := npc.current_task
|
|
release_npc_reservation(npc.id)
|
|
npc.last_task = failed_task
|
|
npc.set_task(SimulationIds.ACTION_WANDER, 2.0)
|
|
npc_task_changed.emit(npc, failed_task, npc.current_task)
|
|
npc_target_requested.emit(npc)
|
|
|
|
if debug_logs:
|
|
print(
|
|
"[SimulationManager] ",
|
|
npc.npc_name,
|
|
" could not navigate for ",
|
|
failed_task,
|
|
" and is trying a wander target"
|
|
)
|
|
return
|
|
|
|
|
|
func notify_npc_target_unavailable(npc_id: int) -> void:
|
|
notify_npc_navigation_failed(npc_id)
|
|
|
|
|
|
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
|
for npc in npcs:
|
|
if npc.id != npc_id:
|
|
continue
|
|
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
|
return false
|
|
if npc.current_task == SimulationIds.ACTION_SLEEP:
|
|
npc.travel_target_position = npc.home_position
|
|
npc.has_travel_target = true
|
|
npc_travel_requested.emit(npc, npc.travel_target_position)
|
|
return true
|
|
if active_world_adapter == null:
|
|
push_error("SimulationManager: active_world_adapter is missing")
|
|
return false
|
|
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
|
|
if result.is_empty():
|
|
notify_npc_target_unavailable(npc.id)
|
|
return false
|
|
npc.target_id = StringName(result.get("target_id", ""))
|
|
npc.travel_target_position = result["position"]
|
|
npc.has_travel_target = true
|
|
npc_travel_requested.emit(npc, npc.travel_target_position)
|
|
return true
|
|
return false
|
|
|
|
|
|
func request_current_travel(npc_id: int) -> bool:
|
|
for npc in npcs:
|
|
if npc.id != npc_id:
|
|
continue
|
|
if npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
|
return false
|
|
if npc.has_travel_target:
|
|
npc_travel_requested.emit(npc, npc.travel_target_position)
|
|
else:
|
|
npc_target_requested.emit(npc)
|
|
return true
|
|
return false
|
|
|
|
|
|
func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool:
|
|
for npc in npcs:
|
|
if npc.id == npc_id:
|
|
npc.position = active_position
|
|
return true
|
|
return false
|
|
|
|
|
|
func get_activity_target_claim_count(target_id: StringName, except_npc_id: int = -1) -> int:
|
|
var count := 0
|
|
for npc in npcs:
|
|
if npc.id == except_npc_id or npc.is_dead:
|
|
continue
|
|
if npc.target_id != target_id:
|
|
continue
|
|
if npc.task_state in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func _npc_inventory_id(npc_id: int) -> StringName:
|
|
return StringName("npc_%d_inventory" % npc_id)
|
|
|
|
|
|
func _record_economic_event(
|
|
event_type: StringName,
|
|
actor_id: int,
|
|
source_id: StringName,
|
|
destination_id: StringName,
|
|
item_id: StringName,
|
|
amount: float
|
|
) -> void:
|
|
if amount <= 0.0:
|
|
return
|
|
var event := EconomicEventRecord.create(
|
|
next_event_id, event_type, tick_count, actor_id, source_id, destination_id, item_id, amount
|
|
)
|
|
next_event_id += 1
|
|
economic_events.append(event)
|
|
economic_event_recorded.emit(event)
|
|
|
|
|
|
func _initialize_storage() -> void:
|
|
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
|
|
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create(
|
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
|
{String(SimulationIds.RESOURCE_FOOD): village.food}
|
|
))
|
|
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
|
|
storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = (StorageStateRecord.create(
|
|
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
|
{String(SimulationIds.RESOURCE_WOOD): village.wood}
|
|
))
|
|
_sync_village_food()
|
|
_sync_village_wood()
|
|
|
|
|
|
func get_pantry() -> StorageStateRecord:
|
|
return storage_states.get(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageStateRecord
|
|
|
|
|
|
func get_woodpile() -> StorageStateRecord:
|
|
return storage_states.get(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageStateRecord
|
|
|
|
|
|
func register_loaded_storage_nodes() -> void:
|
|
for candidate in StorageNode.get_all():
|
|
var node := candidate as StorageNode
|
|
register_storage_node(node)
|
|
|
|
|
|
func register_storage_node(node: StorageNode) -> bool:
|
|
if node == null or node.storage_id.is_empty():
|
|
return false
|
|
var storage_state := storage_states.get(node.storage_id) as StorageStateRecord
|
|
if storage_state == null:
|
|
push_error(
|
|
"SimulationManager: StorageNode '%s' has no authoritative StorageStateRecord"
|
|
% node.storage_id
|
|
)
|
|
return false
|
|
return node.bind_state(storage_state)
|
|
|
|
|
|
func _sync_village_food() -> void:
|
|
var pantry := get_pantry()
|
|
if pantry != null:
|
|
village.food = pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
|
village.update_modifiers()
|
|
village.update_priorities()
|
|
|
|
|
|
func _sync_village_wood() -> void:
|
|
var woodpile := get_woodpile()
|
|
if woodpile != null:
|
|
village.wood = woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
|
village.update_modifiers()
|
|
village.update_priorities()
|
|
|
|
|
|
func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float:
|
|
var available := npc.get_inventory_amount(item_id)
|
|
var deposited := get_pantry().deposit(item_id, available)
|
|
npc.remove_inventory(item_id, deposited)
|
|
_sync_village_food()
|
|
if deposited > 0.0:
|
|
npc_inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
|
|
_record_economic_event(
|
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
|
npc.id,
|
|
_npc_inventory_id(npc.id),
|
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
|
item_id,
|
|
deposited
|
|
)
|
|
return deposited
|
|
|
|
|
|
func deposit_npc_wood(npc: SimNPC) -> float:
|
|
var available := npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
|
|
var woodpile := get_woodpile()
|
|
if woodpile == null:
|
|
return 0.0
|
|
var deposited := woodpile.deposit(SimulationIds.RESOURCE_WOOD, available)
|
|
npc.remove_inventory(SimulationIds.RESOURCE_WOOD, deposited)
|
|
_sync_village_wood()
|
|
if deposited > 0.0:
|
|
npc_inventory_changed.emit(
|
|
npc, SimulationIds.RESOURCE_WOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
|
|
)
|
|
_record_economic_event(
|
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
|
npc.id,
|
|
_npc_inventory_id(npc.id),
|
|
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
|
SimulationIds.RESOURCE_WOOD,
|
|
deposited
|
|
)
|
|
return deposited
|
|
|
|
|
|
func withdraw_to_npc(npc: SimNPC, item_id: StringName, amount: float) -> float:
|
|
var withdrawn := get_pantry().withdraw(item_id, amount)
|
|
npc.add_inventory(item_id, withdrawn)
|
|
_sync_village_food()
|
|
if withdrawn > 0.0:
|
|
npc_inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
|
|
_record_economic_event(
|
|
SimulationIds.EVENT_STORAGE_WITHDRAWN,
|
|
npc.id,
|
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
|
_npc_inventory_id(npc.id),
|
|
item_id,
|
|
withdrawn
|
|
)
|
|
return withdrawn
|
|
|
|
|
|
func consume_npc_food(npc: SimNPC) -> bool:
|
|
if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
|
|
npc.hunger = minf(npc.hunger + 5.0, 100.0)
|
|
npc.is_starving = npc.hunger >= 90.0
|
|
return false
|
|
npc.hunger = maxf(npc.hunger - 55.0, 0.0)
|
|
npc.starvation_ticks = 0
|
|
npc.is_starving = npc.hunger >= 90.0
|
|
npc_inventory_changed.emit(
|
|
npc, SimulationIds.RESOURCE_FOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
|
|
)
|
|
_record_economic_event(
|
|
SimulationIds.EVENT_ITEM_CONSUMED,
|
|
npc.id,
|
|
_npc_inventory_id(npc.id),
|
|
&"consumed",
|
|
SimulationIds.RESOURCE_FOOD,
|
|
1.0
|
|
)
|
|
return true
|
|
|
|
|
|
func register_loaded_resource_nodes() -> void:
|
|
for node in ResourceNode.get_all():
|
|
register_resource_node(node)
|
|
|
|
|
|
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)
|
|
resource_states[node.node_id] = resource_state
|
|
elif not resource_state.apply_definition(node):
|
|
push_error("SimulationManager: ResourceNode definition mismatch for '%s'" % node.node_id)
|
|
return false
|
|
return node.bind_state(resource_state)
|
|
|
|
|
|
func get_resource_state(node_id: StringName) -> ResourceStateRecord:
|
|
return resource_states.get(node_id) as ResourceStateRecord
|
|
|
|
|
|
func reserve_resource(node_id: StringName, agent_id: int) -> bool:
|
|
var resource_state := get_resource_state(node_id)
|
|
return resource_state != null and resource_state.reserve(agent_id)
|
|
|
|
|
|
func release_resource(node_id: StringName, agent_id: int) -> void:
|
|
var resource_state := get_resource_state(node_id)
|
|
if resource_state != null:
|
|
resource_state.release(agent_id)
|
|
|
|
|
|
func find_resource_node_for_player(from_position: Vector3, max_distance: float) -> ResourceNode:
|
|
var best: ResourceNode
|
|
var best_distance := max_distance * max_distance
|
|
for node in ResourceNode.get_all():
|
|
var resource_state := get_resource_state(node.node_id)
|
|
if resource_state == null or not resource_state.can_player_use_resource():
|
|
continue
|
|
var distance := from_position.distance_squared_to(node.interaction_point.global_position)
|
|
if distance <= best_distance:
|
|
best_distance = distance
|
|
best = node
|
|
return best
|
|
|
|
|
|
func harvest_resource_node(node: ResourceNode) -> float:
|
|
if node == null:
|
|
return 0.0
|
|
var resource_state := get_resource_state(node.node_id)
|
|
if resource_state == null or not resource_state.can_player_use_resource():
|
|
return 0.0
|
|
var extracted := resource_state.extract()
|
|
if extracted <= 0.0:
|
|
return 0.0
|
|
|
|
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD:
|
|
get_pantry().deposit(SimulationIds.RESOURCE_FOOD, extracted)
|
|
_sync_village_food()
|
|
elif resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD:
|
|
get_woodpile().deposit(SimulationIds.RESOURCE_WOOD, extracted)
|
|
_sync_village_wood()
|
|
else:
|
|
village.apply_resource_delta(resource_state.get_resource_id(), extracted)
|
|
_record_economic_event(
|
|
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
|
-1,
|
|
resource_state.get_node_id(),
|
|
(
|
|
SimulationIds.STORAGE_VILLAGE_PANTRY
|
|
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD
|
|
else (
|
|
SimulationIds.STORAGE_VILLAGE_WOODPILE
|
|
if resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD
|
|
else &"village"
|
|
)
|
|
),
|
|
resource_state.get_resource_id(),
|
|
extracted
|
|
)
|
|
village_changed.emit(village)
|
|
|
|
if debug_logs:
|
|
print(
|
|
"[SimulationManager] Player extracted ",
|
|
extracted,
|
|
" ",
|
|
resource_state.get_resource_id(),
|
|
" from ",
|
|
resource_state.get_node_id()
|
|
)
|
|
|
|
return extracted
|
|
|
|
|
|
func eat_food(amount: float) -> void:
|
|
get_pantry().withdraw(SimulationIds.RESOURCE_FOOD, amount)
|
|
_sync_village_food()
|
|
village_changed.emit(village)
|
|
|
|
|
|
func add_food(amount: float) -> void:
|
|
if amount >= 0.0:
|
|
get_pantry().deposit(SimulationIds.RESOURCE_FOOD, amount)
|
|
else:
|
|
get_pantry().withdraw(SimulationIds.RESOURCE_FOOD, -amount)
|
|
_sync_village_food()
|
|
village_changed.emit(village)
|
|
|
|
|
|
func add_wood(amount: float) -> void:
|
|
village.apply_resource_delta(&"wood", amount)
|
|
village_changed.emit(village)
|
|
|
|
|
|
func add_safety(amount: float) -> void:
|
|
village.apply_resource_delta(&"safety", amount)
|
|
village_changed.emit(village)
|
|
|
|
|
|
func add_knowledge(amount: float) -> void:
|
|
village.apply_resource_delta(&"knowledge", amount)
|
|
village_changed.emit(village)
|
|
|
|
|
|
func get_starving_count() -> int:
|
|
var count := 0
|
|
for npc in npcs:
|
|
if npc.is_starving:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func get_state_snapshot() -> Dictionary:
|
|
var npc_snapshots: Array[Dictionary] = []
|
|
for npc in npcs:
|
|
npc_snapshots.append(
|
|
{
|
|
"id": npc.id,
|
|
"name": npc.npc_name,
|
|
"profession": npc.profession,
|
|
"hunger": npc.hunger,
|
|
"energy": npc.energy,
|
|
"strength": npc.strength,
|
|
"intelligence": npc.intelligence,
|
|
"task": npc.current_task,
|
|
"task_state": npc.task_state,
|
|
"task_duration": npc.task_duration,
|
|
"task_progress": npc.task_progress,
|
|
"target_id": String(npc.target_id),
|
|
"position": [npc.position.x, npc.position.y, npc.position.z],
|
|
"travel_target_position":
|
|
[
|
|
npc.travel_target_position.x,
|
|
npc.travel_target_position.y,
|
|
npc.travel_target_position.z
|
|
],
|
|
"has_travel_target": npc.has_travel_target,
|
|
"starvation_ticks": npc.starvation_ticks,
|
|
"is_dead": npc.is_dead
|
|
}
|
|
)
|
|
|
|
return {
|
|
"seed": simulation_seed,
|
|
"tick_count": tick_count,
|
|
"village":
|
|
{
|
|
"food": village.food,
|
|
"wood": village.wood,
|
|
"safety": village.safety,
|
|
"knowledge": village.knowledge
|
|
},
|
|
"npcs": npc_snapshots
|
|
}
|
|
|
|
|
|
func get_state_checksum() -> String:
|
|
return create_state_record().to_json().sha256_text()
|
|
|
|
|
|
func get_latest_decision(npc_id: int) -> ActionSelectionResult:
|
|
return latest_decisions.get(npc_id)
|
|
|
|
|
|
func create_state_record() -> SimulationStateRecord:
|
|
var record := SimulationStateRecord.new()
|
|
var wander_streams: Array[Dictionary] = []
|
|
var sorted_npc_ids: Array = wander_random_sources.keys()
|
|
sorted_npc_ids.sort()
|
|
for npc_id in sorted_npc_ids:
|
|
var source: RandomNumberGenerator = wander_random_sources[npc_id]
|
|
wander_streams.append(
|
|
{"npc_id": int(npc_id), "seed": str(source.seed), "state": str(source.state)}
|
|
)
|
|
|
|
record.simulation = {
|
|
"seed": simulation_seed,
|
|
"tick_interval": tick_interval,
|
|
"tick_count": tick_count,
|
|
"clock_accumulator": clock.accumulator,
|
|
"clock_elapsed_ticks": clock.elapsed_ticks,
|
|
"wander_random_streams": wander_streams,
|
|
"next_event_id": next_event_id,
|
|
"cycle_duration_seconds": clock.cycle_duration_seconds
|
|
}
|
|
record.village = VillageStateRecord.capture(village)
|
|
for npc in npcs:
|
|
record.npcs.append(NPCStateRecord.capture(npc))
|
|
|
|
var sorted_resource_ids: Array = resource_states.keys()
|
|
sorted_resource_ids.sort()
|
|
for resource_id in sorted_resource_ids:
|
|
var resource_state: ResourceStateRecord = resource_states[resource_id]
|
|
record.resources.append(resource_state)
|
|
var sorted_storage_ids: Array = storage_states.keys()
|
|
sorted_storage_ids.sort()
|
|
for storage_id in sorted_storage_ids:
|
|
var storage_state: StorageStateRecord = storage_states[storage_id]
|
|
record.storages.append(storage_state)
|
|
for event in economic_events:
|
|
record.economic_events.append(event)
|
|
return record
|
|
|
|
|
|
func serialize_state() -> String:
|
|
return create_state_record().to_json()
|
|
|
|
|
|
func restore_state_from_json(json_text: String) -> bool:
|
|
var record := SimulationStateRecord.from_json(json_text)
|
|
if record == null:
|
|
push_error("SimulationManager: refused invalid or unsupported simulation state")
|
|
return false
|
|
return restore_state(record)
|
|
|
|
|
|
func restore_state(record: SimulationStateRecord) -> bool:
|
|
if record == null:
|
|
return false
|
|
|
|
simulation_seed = int(record.simulation["seed"])
|
|
tick_interval = float(record.simulation["tick_interval"])
|
|
tick_count = int(record.simulation["tick_count"])
|
|
next_event_id = int(record.simulation["next_event_id"])
|
|
clock = SimulationClock.new(tick_interval)
|
|
clock.accumulator = float(record.simulation["clock_accumulator"])
|
|
clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"])
|
|
clock.cycle_duration_seconds = float(record.simulation.get("cycle_duration_seconds", 240.0))
|
|
village = record.village.restore(debug_logs)
|
|
storage_states.clear()
|
|
for storage_record in record.storages:
|
|
storage_states[storage_record.get_storage_id()] = storage_record
|
|
_sync_village_food()
|
|
economic_events = record.economic_events.duplicate()
|
|
latest_decisions.clear()
|
|
|
|
npcs.clear()
|
|
for npc_record in record.npcs:
|
|
npcs.append(npc_record.restore(debug_logs))
|
|
|
|
wander_random_sources.clear()
|
|
var wander_streams: Array = record.simulation["wander_random_streams"]
|
|
for stream_data in wander_streams:
|
|
var source := RandomNumberGenerator.new()
|
|
source.seed = String(stream_data["seed"]).to_int()
|
|
source.state = String(stream_data["state"]).to_int()
|
|
wander_random_sources[int(stream_data["npc_id"])] = source
|
|
|
|
resource_states.clear()
|
|
for resource_record in record.resources:
|
|
resource_states[resource_record.get_node_id()] = resource_record
|
|
register_loaded_resource_nodes()
|
|
|
|
village_changed.emit(village)
|
|
state_restored.emit()
|
|
return true
|