refactor: move resource authority into simulation

This commit is contained in:
2026-07-05 13:26:19 +02:00
parent f83d2c7704
commit 363620b731
10 changed files with 480 additions and 174 deletions
+134 -37
View File
@@ -14,6 +14,7 @@ var npcs: Array[SimNPC] = []
var clock: SimulationClock
var tick_count := 0
var wander_random_sources := {}
var resource_states: Dictionary = {}
var names := [
"Amina", "Tarik", "Jasmin"
@@ -28,11 +29,13 @@ var professions := [
]
func _ready() -> void:
add_to_group("simulation_manager")
clock = SimulationClock.new(tick_interval)
village.debug_logs = debug_logs
village.update_modifiers()
village.update_priorities()
generate_npcs()
call_deferred("register_loaded_resource_nodes")
if debug_logs:
print("--- Simulation started ---")
@@ -110,13 +113,28 @@ func simulate_tick() -> void:
var needs_immediate_food_after_gather := npc.should_eat_immediately_after_task()
if npc.target_id != &"":
var node := ResourceNode.get_by_id(npc.target_id)
if node != null and node.reserved_by == npc.id:
var extracted := node.extract()
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:
village.apply_resource_delta(node.resource_id, extracted)
village.apply_resource_delta(
resource_state.get_resource_id(),
extracted
)
if debug_logs:
print("[SimulationManager] ", npc.npc_name, " extracted ", extracted, " ", node.resource_id, " from ", node.node_id)
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)
@@ -185,9 +203,9 @@ func release_npc_reservation(npc_id: int) -> void:
for npc in npcs:
if npc.id == npc_id:
if npc.target_id != &"":
var node := ResourceNode.get_by_id(npc.target_id)
if node != null:
node.release(npc.id)
var resource_state := get_resource_state(npc.target_id)
if resource_state != null:
resource_state.release(npc.id)
npc.target_id = &""
return
@@ -199,8 +217,12 @@ func notify_npc_arrived(npc_id: int) -> void:
if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
if npc.target_id != &"":
var node := ResourceNode.get_by_id(npc.target_id)
if node == null or not node.can_extract():
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)
@@ -232,19 +254,107 @@ func notify_npc_navigation_failed(npc_id: int) -> void:
func notify_npc_target_unavailable(npc_id: int) -> void:
notify_npc_navigation_failed(npc_id)
func harvest_resource_node(node: ResourceNode) -> float:
if node == null or not node.can_player_use:
return 0.0
func register_loaded_resource_nodes() -> void:
for node in ResourceNode.get_all():
register_resource_node(node)
var extracted := node.extract()
func register_resource_node(node: ResourceNode) -> bool:
if node == null or node.node_id.is_empty():
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 acquire_resource_target(
action_id: StringName,
agent_id: int,
from_position: Vector3
) -> ResourceNode:
var best: ResourceNode
var best_distance := INF
for node in ResourceNode.get_all():
var resource_state := get_resource_state(node.node_id)
if resource_state == null:
continue
if resource_state.get_action_id() != action_id:
continue
if not resource_state.can_npc_use():
continue
if not resource_state.is_available_for(agent_id):
continue
var distance := from_position.distance_squared_to(
node.interaction_point.global_position
)
if distance < best_distance:
best_distance = distance
best = node
if best == null or not reserve_resource(best.node_id, agent_id):
return null
return best
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
village.apply_resource_delta(node.resource_id, extracted)
village.apply_resource_delta(resource_state.get_resource_id(), extracted)
village_changed.emit(village)
if debug_logs:
print("[SimulationManager] Player extracted ", extracted, " ", node.resource_id, " from ", node.node_id)
print(
"[SimulationManager] Player extracted ",
extracted,
" ",
resource_state.get_resource_id(),
" from ",
resource_state.get_node_id()
)
return extracted
@@ -336,17 +446,11 @@ func create_state_record() -> SimulationStateRecord:
for npc in npcs:
record.npcs.append(NPCStateRecord.capture(npc))
var resource_nodes := ResourceNode.get_all()
resource_nodes.sort_custom(
func(a: ResourceNode, b: ResourceNode) -> bool:
return String(a.node_id) < String(b.node_id)
)
var captured_resource_ids := {}
for node in resource_nodes:
if captured_resource_ids.has(node.node_id):
continue
captured_resource_ids[node.node_id] = true
record.resources.append(ResourceStateRecord.capture(node))
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)
return record
func serialize_state() -> String:
@@ -383,17 +487,10 @@ func restore_state(record: SimulationStateRecord) -> bool:
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:
var node_id := StringName(resource_record.data["node_id"])
var node := ResourceNode.get_by_id(node_id)
if node == null:
push_warning(
"SimulationManager: resource state '%s' has no loaded presentation node"
% node_id
)
continue
if not resource_record.restore(node):
return false
resource_states[resource_record.get_node_id()] = resource_record
register_loaded_resource_nodes()
village_changed.emit(village)
return true
+134 -13
View File
@@ -1,42 +1,163 @@
class_name ResourceStateRecord
extends RefCounted
const SCHEMA_VERSION := 1
signal changed(state: ResourceStateRecord)
signal depleted(state: ResourceStateRecord)
const SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1
var data: Dictionary
func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true)
static func capture(node: ResourceNode) -> ResourceStateRecord:
static func create_from_node(node: ResourceNode) -> ResourceStateRecord:
return ResourceStateRecord.new({
"schema_version": SCHEMA_VERSION,
"node_id": String(node.node_id),
"amount_remaining": node.amount_remaining,
"reserved_by": node.reserved_by,
"enabled": node.enabled
"action_id": String(node.action_id),
"resource_id": String(node.resource_id),
"amount_remaining": node.initial_amount,
"yield_per_action": node.yield_per_action,
"reserved_by": -1,
"enabled": node.initial_enabled,
"can_npcs_use": node.can_npcs_use,
"can_player_use": node.can_player_use
})
static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
var version := int(record_data.get("schema_version", -1))
if version == LEGACY_SCHEMA_VERSION:
record_data = _migrate_v1(record_data)
elif version != SCHEMA_VERSION:
return null
if not record_data.has_all([
"node_id", "amount_remaining", "reserved_by", "enabled"
"node_id", "action_id", "resource_id", "amount_remaining",
"yield_per_action", "reserved_by", "enabled", "can_npcs_use",
"can_player_use"
]):
return null
if String(record_data["node_id"]).is_empty():
return null
return ResourceStateRecord.new(record_data)
func restore(node: ResourceNode) -> bool:
if node == null or node.node_id != StringName(data["node_id"]):
static func _migrate_v1(legacy_data: Dictionary) -> Dictionary:
if not legacy_data.has_all([
"node_id", "amount_remaining", "reserved_by", "enabled"
]):
return {}
var migrated := legacy_data.duplicate(true)
migrated["schema_version"] = SCHEMA_VERSION
migrated["action_id"] = ""
migrated["resource_id"] = ""
migrated["yield_per_action"] = 0.0
migrated["can_npcs_use"] = false
migrated["can_player_use"] = false
return migrated
func apply_definition(node: ResourceNode) -> bool:
if node == null or node.node_id != get_node_id():
return false
node.restore_persistent_state(
float(data["amount_remaining"]),
int(data["reserved_by"]),
bool(data["enabled"])
var has_legacy_definition := get_action_id().is_empty()
if has_legacy_definition:
data["action_id"] = String(node.action_id)
data["resource_id"] = String(node.resource_id)
data["yield_per_action"] = node.yield_per_action
data["can_npcs_use"] = node.can_npcs_use
data["can_player_use"] = node.can_player_use
data["schema_version"] = SCHEMA_VERSION
return true
return (
get_action_id() == node.action_id
and get_resource_id() == node.resource_id
and is_equal_approx(get_yield_per_action(), node.yield_per_action)
and can_npc_use() == node.can_npcs_use
and can_player_use_resource() == node.can_player_use
)
func get_node_id() -> StringName:
return StringName(data["node_id"])
func get_action_id() -> StringName:
return StringName(data["action_id"])
func get_resource_id() -> StringName:
return StringName(data["resource_id"])
func get_amount_remaining() -> float:
return float(data["amount_remaining"])
func get_yield_per_action() -> float:
return float(data["yield_per_action"])
func get_reserved_by() -> int:
return int(data["reserved_by"])
func is_enabled() -> bool:
return bool(data["enabled"])
func can_npc_use() -> bool:
return bool(data["can_npcs_use"])
func can_player_use_resource() -> bool:
return bool(data["can_player_use"])
func is_depleted() -> bool:
return get_amount_remaining() <= 0.0
func can_extract() -> bool:
return is_enabled() and not is_depleted()
func is_available_for(agent_id: int) -> bool:
return (
can_extract()
and (get_reserved_by() == -1 or get_reserved_by() == agent_id)
)
func reserve(agent_id: int) -> bool:
if not is_available_for(agent_id):
return false
data["reserved_by"] = agent_id
changed.emit(self)
return true
func release(agent_id: int) -> void:
if get_reserved_by() != agent_id:
return
data["reserved_by"] = -1
changed.emit(self)
func extract(requested_amount: float = -1.0) -> float:
if not can_extract():
return 0.0
var amount := requested_amount
if amount < 0.0:
amount = get_yield_per_action()
var extracted := minf(maxf(amount, 0.0), get_amount_remaining())
data["amount_remaining"] = get_amount_remaining() - extracted
if is_depleted():
data["reserved_by"] = -1
changed.emit(self)
if is_depleted():
depleted.emit(self)
return extracted
func set_amount_remaining(amount: float) -> void:
var was_depleted := is_depleted()
data["amount_remaining"] = maxf(amount, 0.0)
if is_depleted():
data["reserved_by"] = -1
changed.emit(self)
if not was_depleted and is_depleted():
depleted.emit(self)
func set_enabled(value: bool) -> void:
data["enabled"] = value
changed.emit(self)
func to_dictionary() -> Dictionary:
return data.duplicate(true)