diff --git a/main.tscn b/main.tscn index d4497e0..321aa71 100644 --- a/main.tscn +++ b/main.tscn @@ -188,14 +188,14 @@ node_id = &"berry_bush_01" [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) 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")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, -8) node_id = &"tree_01" action_id = &"gather_wood" resource_id = &"wood" -amount_remaining = 15.0 +initial_amount = 15.0 yield_per_action = 3.0 [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" action_id = &"gather_wood" resource_id = &"wood" -amount_remaining = 12.0 +initial_amount = 12.0 yield_per_action = 3.0 diff --git a/player/player.gd b/player/player.gd index 6bd7348..cc1bb60 100644 --- a/player/player.gd +++ b/player/player.gd @@ -74,11 +74,17 @@ func try_interact() -> void: print("Player ate food from the village supply.") 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: return false - if not node.enabled: + if not node.is_enabled(): print("%s is unavailable." % node.node_id) return true diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 89fdb74..90a7e84 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -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 diff --git a/simulation/state/ResourceStateRecord.gd b/simulation/state/ResourceStateRecord.gd index eba911c..09046a2 100644 --- a/simulation/state/ResourceStateRecord.gd +++ b/simulation/state/ResourceStateRecord.gd @@ -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) diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index bf4ebeb..eaa1d44 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -87,11 +87,26 @@ func _run() -> void: "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") if selected != null: - _check(selected.reserve(777), "Selected Jajce resource should be reservable") - selected.release(777) + var selected_state: ResourceStateRecord = manager.get_resource_state( + 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(): print("[TEST] Jajce scaffold passed: Terrain3D, stable IDs, 24 navigation routes") diff --git a/tests/resource_node_player_parity_test.gd b/tests/resource_node_player_parity_test.gd index 56c6f9e..3be4b72 100644 --- a/tests/resource_node_player_parity_test.gd +++ b/tests/resource_node_player_parity_test.gd @@ -18,22 +18,43 @@ func _run() -> void: var tree := main_scene.get_node("ResourceNodes/Tree_01") as ResourceNode simulation_manager.set_process(false) - bush.amount_remaining = 1.0 - _check(bush.reserve(999), "Player contention setup should reserve the bush") + var bush_state: ResourceStateRecord = simulation_manager.get_resource_state( + 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 var food_before: float = simulation_manager.village.food 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(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 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() - _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") if failures.is_empty(): diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index 2531e6b..094a611 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -8,6 +8,7 @@ func _initialize() -> void: func _run() -> void: await _test_deterministic_continuation() await _test_resource_state_round_trip() + _test_legacy_resource_migration() _test_schema_rejection() if failures.is_empty(): @@ -64,33 +65,65 @@ func _test_resource_state_round_trip() -> void: "res://world/resource_nodes/ResourceNode.tscn" ).instantiate() resource.node_id = &"SerializationTestBerry" - resource.amount_remaining = 9.0 + resource.initial_amount = 9.0 resource.yield_per_action = 2.0 root.add_child(resource) await process_frame - _check(resource.reserve(42), "Resource should accept the test reservation") - resource.extract() - 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() - resource.extract(5.0) - resource.release(42) - resource.enabled = false + resource.free() + + _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( 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( - is_equal_approx(resource.amount_remaining, 7.0), + is_equal_approx(resource_state.get_amount_remaining(), 7.0), "Resource amount should round-trip" ) - _check(resource.reserved_by == 42, "Resource reservation should round-trip") - _check(resource.enabled, "Resource enabled state should round-trip") + _check( + 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() - resource.free() + rebound.free() func _test_schema_rejection() -> void: var unsupported := JSON.stringify({ @@ -106,6 +139,27 @@ func _test_schema_rejection() -> void: "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: var manager: Node = load("res://simulation/SimulationManager.gd").new() manager.simulation_seed = seed_value diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index ca29d34..1db5e1b 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -157,14 +157,14 @@ node_id = &"berry_bush_01" [node name="BerryBush_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(-10, 0, -4) node_id = &"berry_bush_02" -amount_remaining = 8.0 +initial_amount = 8.0 [node name="Tree_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(6, 0, -8) node_id = &"tree_01" action_id = &"gather_wood" resource_id = &"wood" -amount_remaining = 15.0 +initial_amount = 15.0 yield_per_action = 3.0 [node name="Tree_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] @@ -172,7 +172,7 @@ position = Vector3(10, 0, -4) node_id = &"tree_02" action_id = &"gather_wood" resource_id = &"wood" -amount_remaining = 12.0 +initial_amount = 12.0 yield_per_action = 3.0 [node name="LegacyActivityMarkers" type="Node3D" parent="."] diff --git a/world/resource_nodes/ResourceNode.gd b/world/resource_nodes/ResourceNode.gd index feca3bb..8dceeb4 100644 --- a/world/resource_nodes/ResourceNode.gd +++ b/world/resource_nodes/ResourceNode.gd @@ -10,129 +10,118 @@ static var _all: Array[ResourceNode] = [] @export var node_id: StringName @export var action_id: StringName = &"gather_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 enabled: bool = true +@export var initial_enabled: bool = true @export var can_npcs_use: bool = true @export var can_player_use: bool = true @export var hide_visual_when_depleted: bool = false @onready var interaction_point: Marker3D = $InteractionPoint -var reserved_by: int = -1 +var state: ResourceStateRecord -func _ready(): +func _ready() -> void: if node_id.is_empty(): push_error("ResourceNode at %s has empty node_id" % get_path()) - enabled = false - else: - var existing := get_by_id(node_id) - if existing != null: - push_error("Duplicate ResourceNode node_id '%s' at %s; first registered at %s" % [node_id, get_path(), existing.get_path()]) - enabled = false + initial_enabled = false + _update_presentation() + return + var existing := get_by_id(node_id) + if existing != null: + 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) add_to_group("resource_nodes") - amount_changed.connect(_update_debug_label) - reservation_changed.connect(_update_debug_label) - _update_debug_label() + _try_register_with_simulation() + _update_presentation() -func _exit_tree(): +func _exit_tree() -> void: _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: - return amount_remaining <= 0.0 - -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): +func bind_state(resource_state: ResourceStateRecord) -> bool: + if resource_state == null or resource_state.get_node_id() != node_id: return false - reserved_by = agent_id - reservation_changed.emit(node_id, agent_id) + 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 = 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 -func release(agent_id: int) -> void: - if reserved_by == agent_id: - reserved_by = -1 - reservation_changed.emit(node_id, -1) +func _try_register_with_simulation() -> void: + var managers := get_tree().get_nodes_in_group("simulation_manager") + if managers.size() != 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: - if not can_extract(): - return 0.0 - var extracted := minf(requested_amount, amount_remaining) - amount_remaining -= extracted - amount_changed.emit(node_id, amount_remaining) - if is_depleted(): - if reserved_by >= 0: - reserved_by = -1 - reservation_changed.emit(node_id, -1) +func get_amount_remaining() -> float: + return state.get_amount_remaining() if state != null else initial_amount + +func get_reserved_by() -> int: + return state.get_reserved_by() if state != null else -1 + +func is_enabled() -> bool: + return state.is_enabled() if state != null else initial_enabled + +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) - if hide_visual_when_depleted and has_node("Visual"): - $Visual.visible = false - return extracted -func restore_persistent_state( - 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) +func _update_presentation() -> void: if has_node("Visual"): $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"): return var label := $DebugLabel as Label3D - var text := "%s\n%s %.1f" % [node_id, resource_id, amount_remaining] - if reserved_by >= 0: - text += "\nheld:%d" % reserved_by + var text := "%s\n%s %.1f" % [ + node_id, + resource_id, + get_amount_remaining() + ] + if get_reserved_by() >= 0: + text += "\nheld:%d" % get_reserved_by() if is_depleted(): text += "\nDEPLETED" + if state == null: + text += "\nUNBOUND" 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: - if node.node_id == node_id: + if node.node_id == search_id: return node return null static func get_all() -> Array[ResourceNode]: 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 diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index b3cc00e..ccd0400 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -139,15 +139,18 @@ func _try_get_resource_target(npc: SimNPC, task: String) -> Variant: if visual == 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: debug_log("No available resource node for %s" % task) 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 debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task]) return node.interaction_point.global_position