refactor: move resource authority into simulation
This commit is contained in:
@@ -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
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
|
||||
+134
-37
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="."]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user