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
+3 -3
View File
@@ -188,14 +188,14 @@ node_id = &"berry_bush_01"
[node name="BerryBush_02" parent="ResourceNodes" unique_id=2100422483 instance=ExtResource("8_r3s0u")] [node name="BerryBush_02" parent="ResourceNodes" unique_id=2100422483 instance=ExtResource("8_r3s0u")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10, 0, -4) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10, 0, -4)
node_id = &"berry_bush_02" node_id = &"berry_bush_02"
amount_remaining = 8.0 initial_amount = 8.0
[node name="Tree_01" parent="ResourceNodes" unique_id=1162125845 instance=ExtResource("8_r3s0u")] [node name="Tree_01" parent="ResourceNodes" unique_id=1162125845 instance=ExtResource("8_r3s0u")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, -8) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, -8)
node_id = &"tree_01" node_id = &"tree_01"
action_id = &"gather_wood" action_id = &"gather_wood"
resource_id = &"wood" resource_id = &"wood"
amount_remaining = 15.0 initial_amount = 15.0
yield_per_action = 3.0 yield_per_action = 3.0
[node name="Tree_02" parent="ResourceNodes" unique_id=1626186397 instance=ExtResource("8_r3s0u")] [node name="Tree_02" parent="ResourceNodes" unique_id=1626186397 instance=ExtResource("8_r3s0u")]
@@ -203,5 +203,5 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, -4)
node_id = &"tree_02" node_id = &"tree_02"
action_id = &"gather_wood" action_id = &"gather_wood"
resource_id = &"wood" resource_id = &"wood"
amount_remaining = 12.0 initial_amount = 12.0
yield_per_action = 3.0 yield_per_action = 3.0
+8 -2
View File
@@ -74,11 +74,17 @@ func try_interact() -> void:
print("Player ate food from the village supply.") print("Player ate food from the village supply.")
func try_harvest_resource_node() -> bool: func try_harvest_resource_node() -> bool:
var node := ResourceNode.find_nearest_for_player(global_position, interaction_range) if not simulation_manager.has_method("find_resource_node_for_player"):
push_error("Player: SimulationManager cannot find ResourceNodes")
return false
var node: ResourceNode = simulation_manager.find_resource_node_for_player(
global_position,
interaction_range
)
if node == null: if node == null:
return false return false
if not node.enabled: if not node.is_enabled():
print("%s is unavailable." % node.node_id) print("%s is unavailable." % node.node_id)
return true return true
+134 -37
View File
@@ -14,6 +14,7 @@ var npcs: Array[SimNPC] = []
var clock: SimulationClock var clock: SimulationClock
var tick_count := 0 var tick_count := 0
var wander_random_sources := {} var wander_random_sources := {}
var resource_states: Dictionary = {}
var names := [ var names := [
"Amina", "Tarik", "Jasmin" "Amina", "Tarik", "Jasmin"
@@ -28,11 +29,13 @@ var professions := [
] ]
func _ready() -> void: func _ready() -> void:
add_to_group("simulation_manager")
clock = SimulationClock.new(tick_interval) clock = SimulationClock.new(tick_interval)
village.debug_logs = debug_logs village.debug_logs = debug_logs
village.update_modifiers() village.update_modifiers()
village.update_priorities() village.update_priorities()
generate_npcs() generate_npcs()
call_deferred("register_loaded_resource_nodes")
if debug_logs: if debug_logs:
print("--- Simulation started ---") print("--- Simulation started ---")
@@ -110,13 +113,28 @@ func simulate_tick() -> void:
var needs_immediate_food_after_gather := npc.should_eat_immediately_after_task() var needs_immediate_food_after_gather := npc.should_eat_immediately_after_task()
if npc.target_id != &"": if npc.target_id != &"":
var node := ResourceNode.get_by_id(npc.target_id) var resource_state := get_resource_state(npc.target_id)
if node != null and node.reserved_by == npc.id: if (
var extracted := node.extract() resource_state != null
and resource_state.get_reserved_by() == npc.id
):
var extracted := resource_state.extract()
if extracted > 0: if extracted > 0:
village.apply_resource_delta(node.resource_id, extracted) village.apply_resource_delta(
resource_state.get_resource_id(),
extracted
)
if debug_logs: 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: elif debug_logs:
print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": target reservation was invalid") print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": target reservation was invalid")
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
@@ -185,9 +203,9 @@ func release_npc_reservation(npc_id: int) -> void:
for npc in npcs: for npc in npcs:
if npc.id == npc_id: if npc.id == npc_id:
if npc.target_id != &"": if npc.target_id != &"":
var node := ResourceNode.get_by_id(npc.target_id) var resource_state := get_resource_state(npc.target_id)
if node != null: if resource_state != null:
node.release(npc.id) resource_state.release(npc.id)
npc.target_id = &"" npc.target_id = &""
return return
@@ -199,8 +217,12 @@ func notify_npc_arrived(npc_id: int) -> void:
if npc.task_state == SimNPC.TASK_STATE_TRAVELING: if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
if npc.target_id != &"": if npc.target_id != &"":
var node := ResourceNode.get_by_id(npc.target_id) var resource_state := get_resource_state(npc.target_id)
if node == null or not node.can_extract(): if (
resource_state == null
or not resource_state.can_extract()
or resource_state.get_reserved_by() != npc.id
):
if debug_logs: if debug_logs:
print("[SimulationManager] ", npc.npc_name, " arrived but target ", npc.target_id, " is unavailable, replanning") print("[SimulationManager] ", npc.npc_name, " arrived but target ", npc.target_id, " is unavailable, replanning")
notify_npc_navigation_failed(npc.id) 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: func notify_npc_target_unavailable(npc_id: int) -> void:
notify_npc_navigation_failed(npc_id) notify_npc_navigation_failed(npc_id)
func harvest_resource_node(node: ResourceNode) -> float: func register_loaded_resource_nodes() -> void:
if node == null or not node.can_player_use: for node in ResourceNode.get_all():
return 0.0 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: if extracted <= 0.0:
return 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) village_changed.emit(village)
if debug_logs: 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 return extracted
@@ -336,17 +446,11 @@ func create_state_record() -> SimulationStateRecord:
for npc in npcs: for npc in npcs:
record.npcs.append(NPCStateRecord.capture(npc)) record.npcs.append(NPCStateRecord.capture(npc))
var resource_nodes := ResourceNode.get_all() var sorted_resource_ids: Array = resource_states.keys()
resource_nodes.sort_custom( sorted_resource_ids.sort()
func(a: ResourceNode, b: ResourceNode) -> bool: for resource_id in sorted_resource_ids:
return String(a.node_id) < String(b.node_id) var resource_state: ResourceStateRecord = resource_states[resource_id]
) record.resources.append(resource_state)
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))
return record return record
func serialize_state() -> String: func serialize_state() -> String:
@@ -383,17 +487,10 @@ func restore_state(record: SimulationStateRecord) -> bool:
source.state = String(stream_data["state"]).to_int() source.state = String(stream_data["state"]).to_int()
wander_random_sources[int(stream_data["npc_id"])] = source wander_random_sources[int(stream_data["npc_id"])] = source
resource_states.clear()
for resource_record in record.resources: for resource_record in record.resources:
var node_id := StringName(resource_record.data["node_id"]) resource_states[resource_record.get_node_id()] = resource_record
var node := ResourceNode.get_by_id(node_id) register_loaded_resource_nodes()
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
village_changed.emit(village) village_changed.emit(village)
return true return true
+134 -13
View File
@@ -1,42 +1,163 @@
class_name ResourceStateRecord class_name ResourceStateRecord
extends RefCounted 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 var data: Dictionary
func _init(record_data: Dictionary = {}) -> void: func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true) data = record_data.duplicate(true)
static func capture(node: ResourceNode) -> ResourceStateRecord: static func create_from_node(node: ResourceNode) -> ResourceStateRecord:
return ResourceStateRecord.new({ return ResourceStateRecord.new({
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
"node_id": String(node.node_id), "node_id": String(node.node_id),
"amount_remaining": node.amount_remaining, "action_id": String(node.action_id),
"reserved_by": node.reserved_by, "resource_id": String(node.resource_id),
"enabled": node.enabled "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: 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 return null
if not record_data.has_all([ 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 return null
if String(record_data["node_id"]).is_empty(): if String(record_data["node_id"]).is_empty():
return null return null
return ResourceStateRecord.new(record_data) return ResourceStateRecord.new(record_data)
func restore(node: ResourceNode) -> bool: static func _migrate_v1(legacy_data: Dictionary) -> Dictionary:
if node == null or node.node_id != StringName(data["node_id"]): 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 return false
node.restore_persistent_state(
float(data["amount_remaining"]), var has_legacy_definition := get_action_id().is_empty()
int(data["reserved_by"]), if has_legacy_definition:
bool(data["enabled"]) 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 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: func to_dictionary() -> Dictionary:
return data.duplicate(true) return data.duplicate(true)
+18 -3
View File
@@ -87,11 +87,26 @@ func _run() -> void:
"Jajce navigation path should exist from %s to %s" % [origin, destination] "Jajce navigation path should exist from %s to %s" % [origin, destination]
) )
var selected := ResourceNode.find_available(&"gather_food", 777, Vector3.ZERO) var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.debug_logs = false
root.add_child(manager)
manager.set_process(false)
await process_frame
var selected: ResourceNode = manager.acquire_resource_target(
&"gather_food",
777,
Vector3.ZERO
)
_check(selected != null, "Target discovery should find Jajce resources without a parent path") _check(selected != null, "Target discovery should find Jajce resources without a parent path")
if selected != null: if selected != null:
_check(selected.reserve(777), "Selected Jajce resource should be reservable") var selected_state: ResourceStateRecord = manager.get_resource_state(
selected.release(777) selected.node_id
)
_check(
selected_state.get_reserved_by() == 777,
"Selected Jajce resource should be authoritatively reserved"
)
manager.release_resource(selected.node_id, 777)
if failures.is_empty(): if failures.is_empty():
print("[TEST] Jajce scaffold passed: Terrain3D, stable IDs, 24 navigation routes") print("[TEST] Jajce scaffold passed: Terrain3D, stable IDs, 24 navigation routes")
+27 -6
View File
@@ -18,22 +18,43 @@ func _run() -> void:
var tree := main_scene.get_node("ResourceNodes/Tree_01") as ResourceNode var tree := main_scene.get_node("ResourceNodes/Tree_01") as ResourceNode
simulation_manager.set_process(false) simulation_manager.set_process(false)
bush.amount_remaining = 1.0 var bush_state: ResourceStateRecord = simulation_manager.get_resource_state(
_check(bush.reserve(999), "Player contention setup should reserve the bush") bush.node_id
)
var tree_state: ResourceStateRecord = simulation_manager.get_resource_state(
tree.node_id
)
bush_state.set_amount_remaining(1.0)
_check(
simulation_manager.reserve_resource(bush.node_id, 999),
"Player contention setup should reserve the bush"
)
player.global_position = bush.interaction_point.global_position player.global_position = bush.interaction_point.global_position
var food_before: float = simulation_manager.village.food var food_before: float = simulation_manager.village.food
player.try_interact() player.try_interact()
_check(is_equal_approx(bush.amount_remaining, 0.0), "Player should deplete the nearby bush") _check(
is_equal_approx(bush_state.get_amount_remaining(), 0.0),
"Player should deplete the nearby bush"
)
_check(is_equal_approx(simulation_manager.village.food, food_before + 1.0), "Village should receive exactly the bush's remaining food") _check(is_equal_approx(simulation_manager.village.food, food_before + 1.0), "Village should receive exactly the bush's remaining food")
_check(bush.reserved_by == -1, "Depletion should release the NPC reservation") _check(
bush_state.get_reserved_by() == -1,
"Depletion should release the NPC reservation"
)
player.global_position = tree.interaction_point.global_position player.global_position = tree.interaction_point.global_position
var wood_before: float = simulation_manager.village.wood var wood_before: float = simulation_manager.village.wood
var tree_before := tree.amount_remaining var tree_before := tree_state.get_amount_remaining()
player.try_interact() player.try_interact()
_check(is_equal_approx(tree.amount_remaining, tree_before - tree.yield_per_action), "Player should extract the tree's configured yield") _check(
is_equal_approx(
tree_state.get_amount_remaining(),
tree_before - tree.yield_per_action
),
"Player should extract the tree's configured yield"
)
_check(is_equal_approx(simulation_manager.village.wood, wood_before + tree.yield_per_action), "Village should receive exactly the extracted wood") _check(is_equal_approx(simulation_manager.village.wood, wood_before + tree.yield_per_action), "Village should receive exactly the extracted wood")
if failures.is_empty(): if failures.is_empty():
+66 -12
View File
@@ -8,6 +8,7 @@ func _initialize() -> void:
func _run() -> void: func _run() -> void:
await _test_deterministic_continuation() await _test_deterministic_continuation()
await _test_resource_state_round_trip() await _test_resource_state_round_trip()
_test_legacy_resource_migration()
_test_schema_rejection() _test_schema_rejection()
if failures.is_empty(): if failures.is_empty():
@@ -64,33 +65,65 @@ func _test_resource_state_round_trip() -> void:
"res://world/resource_nodes/ResourceNode.tscn" "res://world/resource_nodes/ResourceNode.tscn"
).instantiate() ).instantiate()
resource.node_id = &"SerializationTestBerry" resource.node_id = &"SerializationTestBerry"
resource.amount_remaining = 9.0 resource.initial_amount = 9.0
resource.yield_per_action = 2.0 resource.yield_per_action = 2.0
root.add_child(resource) root.add_child(resource)
await process_frame await process_frame
_check(resource.reserve(42), "Resource should accept the test reservation")
resource.extract()
var manager := _create_manager(77) var manager := _create_manager(77)
_check(
manager.register_resource_node(resource),
"Resource presentation should bind to simulation authority"
)
var resource_state: ResourceStateRecord = manager.get_resource_state(
resource.node_id
)
_check(resource_state.reserve(42), "Resource should accept the test reservation")
resource_state.extract()
var saved_json: String = manager.serialize_state() var saved_json: String = manager.serialize_state()
resource.extract(5.0) resource.free()
resource.release(42)
resource.enabled = false _check(
is_equal_approx(resource_state.get_amount_remaining(), 7.0),
"Resource state should survive without its presentation node"
)
resource_state.extract(5.0)
resource_state.release(42)
resource_state.set_enabled(false)
_check( _check(
manager.restore_state_from_json(saved_json), manager.restore_state_from_json(saved_json),
"Resource state should restore with the simulation record" "Unloaded resource state should restore with the simulation record"
) )
resource_state = manager.get_resource_state(&"SerializationTestBerry")
_check( _check(
is_equal_approx(resource.amount_remaining, 7.0), is_equal_approx(resource_state.get_amount_remaining(), 7.0),
"Resource amount should round-trip" "Resource amount should round-trip"
) )
_check(resource.reserved_by == 42, "Resource reservation should round-trip") _check(
_check(resource.enabled, "Resource enabled state should round-trip") resource_state.get_reserved_by() == 42,
"Resource reservation should round-trip"
)
_check(resource_state.is_enabled(), "Resource enabled state should round-trip")
var rebound: ResourceNode = load(
"res://world/resource_nodes/ResourceNode.tscn"
).instantiate()
rebound.node_id = &"SerializationTestBerry"
rebound.initial_amount = 999.0
root.add_child(rebound)
await process_frame
_check(
rebound.state == resource_state,
"Reloaded presentation should bind to the existing authoritative record"
)
_check(
is_equal_approx(rebound.get_amount_remaining(), 7.0),
"Reloaded presentation must not overwrite authoritative amount"
)
manager.free() manager.free()
resource.free() rebound.free()
func _test_schema_rejection() -> void: func _test_schema_rejection() -> void:
var unsupported := JSON.stringify({ var unsupported := JSON.stringify({
@@ -106,6 +139,27 @@ func _test_schema_rejection() -> void:
"Non-record JSON should be rejected" "Non-record JSON should be rejected"
) )
func _test_legacy_resource_migration() -> void:
var migrated := ResourceStateRecord.from_dictionary({
"schema_version": 1,
"node_id": "legacy_tree",
"amount_remaining": 6.0,
"reserved_by": 12,
"enabled": true
})
_check(migrated != null, "ResourceStateRecord v1 should migrate explicitly")
if migrated == null:
return
_check(
migrated.data["schema_version"] == ResourceStateRecord.SCHEMA_VERSION,
"Migrated resource state should use the current nested schema"
)
_check(
is_equal_approx(migrated.get_amount_remaining(), 6.0)
and migrated.get_reserved_by() == 12,
"Resource migration should preserve mutable authority"
)
func _create_manager(seed_value: int) -> Node: func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new() var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value manager.simulation_seed = seed_value
+3 -3
View File
@@ -157,14 +157,14 @@ node_id = &"berry_bush_01"
[node name="BerryBush_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] [node name="BerryBush_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-10, 0, -4) position = Vector3(-10, 0, -4)
node_id = &"berry_bush_02" node_id = &"berry_bush_02"
amount_remaining = 8.0 initial_amount = 8.0
[node name="Tree_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] [node name="Tree_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(6, 0, -8) position = Vector3(6, 0, -8)
node_id = &"tree_01" node_id = &"tree_01"
action_id = &"gather_wood" action_id = &"gather_wood"
resource_id = &"wood" resource_id = &"wood"
amount_remaining = 15.0 initial_amount = 15.0
yield_per_action = 3.0 yield_per_action = 3.0
[node name="Tree_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] [node name="Tree_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
@@ -172,7 +172,7 @@ position = Vector3(10, 0, -4)
node_id = &"tree_02" node_id = &"tree_02"
action_id = &"gather_wood" action_id = &"gather_wood"
resource_id = &"wood" resource_id = &"wood"
amount_remaining = 12.0 initial_amount = 12.0
yield_per_action = 3.0 yield_per_action = 3.0
[node name="LegacyActivityMarkers" type="Node3D" parent="."] [node name="LegacyActivityMarkers" type="Node3D" parent="."]
+79 -90
View File
@@ -10,129 +10,118 @@ static var _all: Array[ResourceNode] = []
@export var node_id: StringName @export var node_id: StringName
@export var action_id: StringName = &"gather_food" @export var action_id: StringName = &"gather_food"
@export var resource_id: StringName = &"food" @export var resource_id: StringName = &"food"
@export var amount_remaining: float = 10.0 @export var initial_amount: float = 10.0
@export var yield_per_action: float = 2.0 @export var yield_per_action: float = 2.0
@export var enabled: bool = true @export var initial_enabled: bool = true
@export var can_npcs_use: bool = true @export var can_npcs_use: bool = true
@export var can_player_use: bool = true @export var can_player_use: bool = true
@export var hide_visual_when_depleted: bool = false @export var hide_visual_when_depleted: bool = false
@onready var interaction_point: Marker3D = $InteractionPoint @onready var interaction_point: Marker3D = $InteractionPoint
var reserved_by: int = -1 var state: ResourceStateRecord
func _ready(): func _ready() -> void:
if node_id.is_empty(): if node_id.is_empty():
push_error("ResourceNode at %s has empty node_id" % get_path()) push_error("ResourceNode at %s has empty node_id" % get_path())
enabled = false initial_enabled = false
else: _update_presentation()
var existing := get_by_id(node_id) return
if existing != null: var existing := get_by_id(node_id)
push_error("Duplicate ResourceNode node_id '%s' at %s; first registered at %s" % [node_id, get_path(), existing.get_path()]) if existing != null:
enabled = false push_error(
"Duplicate ResourceNode node_id '%s' at %s; first registered at %s"
% [node_id, get_path(), existing.get_path()]
)
initial_enabled = false
_update_presentation()
return
_all.append(self) _all.append(self)
add_to_group("resource_nodes") add_to_group("resource_nodes")
amount_changed.connect(_update_debug_label) _try_register_with_simulation()
reservation_changed.connect(_update_debug_label) _update_presentation()
_update_debug_label()
func _exit_tree(): func _exit_tree() -> void:
_all.erase(self) _all.erase(self)
if state != null and state.changed.is_connected(_on_state_changed):
state.changed.disconnect(_on_state_changed)
if state != null and state.depleted.is_connected(_on_state_depleted):
state.depleted.disconnect(_on_state_depleted)
state = null
func is_depleted() -> bool: func bind_state(resource_state: ResourceStateRecord) -> bool:
return amount_remaining <= 0.0 if resource_state == null or resource_state.get_node_id() != node_id:
func can_extract() -> bool:
return enabled and not is_depleted()
func is_available_for(agent_id: int) -> bool:
return enabled and not is_depleted() and (reserved_by == -1 or reserved_by == agent_id)
func reserve(agent_id: int) -> bool:
if not is_available_for(agent_id):
return false return false
reserved_by = agent_id if state != null and state.changed.is_connected(_on_state_changed):
reservation_changed.emit(node_id, agent_id) state.changed.disconnect(_on_state_changed)
if state != null and state.depleted.is_connected(_on_state_depleted):
state.depleted.disconnect(_on_state_depleted)
state = resource_state
if not state.changed.is_connected(_on_state_changed):
state.changed.connect(_on_state_changed)
if not state.depleted.is_connected(_on_state_depleted):
state.depleted.connect(_on_state_depleted)
_update_presentation()
return true return true
func release(agent_id: int) -> void: func _try_register_with_simulation() -> void:
if reserved_by == agent_id: var managers := get_tree().get_nodes_in_group("simulation_manager")
reserved_by = -1 if managers.size() != 1:
reservation_changed.emit(node_id, -1) return
var manager := managers[0]
if manager.has_method("register_resource_node"):
manager.register_resource_node(self)
func extract(requested_amount: float = yield_per_action) -> float: func get_amount_remaining() -> float:
if not can_extract(): return state.get_amount_remaining() if state != null else initial_amount
return 0.0
var extracted := minf(requested_amount, amount_remaining) func get_reserved_by() -> int:
amount_remaining -= extracted return state.get_reserved_by() if state != null else -1
amount_changed.emit(node_id, amount_remaining)
if is_depleted(): func is_enabled() -> bool:
if reserved_by >= 0: return state.is_enabled() if state != null else initial_enabled
reserved_by = -1
reservation_changed.emit(node_id, -1) func is_depleted() -> bool:
return state.is_depleted() if state != null else initial_amount <= 0.0
func can_extract() -> bool:
return state.can_extract() if state != null else initial_enabled and not is_depleted()
func _on_state_changed(changed_state: ResourceStateRecord) -> void:
if changed_state != state:
return
amount_changed.emit(node_id, state.get_amount_remaining())
reservation_changed.emit(node_id, state.get_reserved_by())
_update_presentation()
func _on_state_depleted(depleted_state: ResourceStateRecord) -> void:
if depleted_state == state:
depleted.emit(node_id) depleted.emit(node_id)
if hide_visual_when_depleted and has_node("Visual"):
$Visual.visible = false
return extracted
func restore_persistent_state( func _update_presentation() -> void:
saved_amount: float,
saved_reserved_by: int,
saved_enabled: bool
) -> void:
amount_remaining = maxf(saved_amount, 0.0)
reserved_by = saved_reserved_by if amount_remaining > 0.0 else -1
enabled = saved_enabled
amount_changed.emit(node_id, amount_remaining)
reservation_changed.emit(node_id, reserved_by)
if has_node("Visual"): if has_node("Visual"):
$Visual.visible = not (hide_visual_when_depleted and is_depleted()) $Visual.visible = not (hide_visual_when_depleted and is_depleted())
_update_debug_label()
func _update_debug_label(_a = null, _b = null) -> void:
if not has_node("DebugLabel"): if not has_node("DebugLabel"):
return return
var label := $DebugLabel as Label3D var label := $DebugLabel as Label3D
var text := "%s\n%s %.1f" % [node_id, resource_id, amount_remaining] var text := "%s\n%s %.1f" % [
if reserved_by >= 0: node_id,
text += "\nheld:%d" % reserved_by resource_id,
get_amount_remaining()
]
if get_reserved_by() >= 0:
text += "\nheld:%d" % get_reserved_by()
if is_depleted(): if is_depleted():
text += "\nDEPLETED" text += "\nDEPLETED"
if state == null:
text += "\nUNBOUND"
label.text = text label.text = text
static func get_by_id(node_id: StringName) -> ResourceNode: static func get_by_id(search_id: StringName) -> ResourceNode:
for node in _all: for node in _all:
if node.node_id == node_id: if node.node_id == search_id:
return node return node
return null return null
static func get_all() -> Array[ResourceNode]: static func get_all() -> Array[ResourceNode]:
return _all.duplicate() return _all.duplicate()
static func find_available(action: StringName, agent_id: int, from_position: Vector3) -> ResourceNode:
var best: ResourceNode
var best_dist := INF
for node in _all:
if node.action_id != action:
continue
if not node.can_npcs_use:
continue
if not node.is_available_for(agent_id):
continue
var dist := from_position.distance_squared_to(node.interaction_point.global_position)
if dist < best_dist:
best_dist = dist
best = node
return best
static func find_nearest_for_player(from_position: Vector3, max_distance: float) -> ResourceNode:
var best: ResourceNode
var best_dist := max_distance * max_distance
for node in _all:
if not node.can_player_use:
continue
var dist := from_position.distance_squared_to(node.interaction_point.global_position)
if dist <= best_dist:
best_dist = dist
best = node
return best
+8 -5
View File
@@ -139,15 +139,18 @@ func _try_get_resource_target(npc: SimNPC, task: String) -> Variant:
if visual == null: if visual == null:
return null return null
var node := ResourceNode.find_available(action_id, npc.id, visual.global_position) if not simulation_manager.has_method("acquire_resource_target"):
push_error("WorldViewManager: SimulationManager cannot acquire resources")
return null
var node: ResourceNode = simulation_manager.acquire_resource_target(
action_id,
npc.id,
visual.global_position
)
if node == null: if node == null:
debug_log("No available resource node for %s" % task) debug_log("No available resource node for %s" % task)
return null return null
if not node.reserve(npc.id):
debug_log("Failed to reserve %s for %s" % [node.node_id, npc.npc_name])
return null
npc.target_id = node.node_id npc.target_id = node.node_id
debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task]) debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task])
return node.interaction_point.global_position return node.interaction_point.global_position