From 036c9d51418847a30a502c2f4477eee8df1bbc8a Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Tue, 7 Jul 2026 15:10:06 +0200 Subject: [PATCH] feat: score resource discovery by comfort and safety --- docs/LEARNING_ROADMAP.md | 5 ++- docs/PROJECT_CONTEXT.md | 4 +- docs/RESOURCE_NODE_MIGRATION.md | 8 ++-- docs/SIMULATION_STATE_SCHEMA.md | 11 +++--- simulation/actions/ActionTargetResolver.gd | 22 +++++++++-- simulation/state/ResourceStateRecord.gd | 39 ++++++++++++++++++-- tests/action_system_boundaries_test.gd | 31 ++++++++++++++-- tests/food_storage_loop_test.gd | 5 ++- tests/jajce_world_scaffold_test.gd | 10 +++++ tests/simulation_state_serialization_test.gd | 5 +++ world/jajce/JajceWorld.tscn | 10 +++++ world/resource_nodes/ResourceNode.gd | 5 +++ 12 files changed, 132 insertions(+), 23 deletions(-) diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index ad34e75..c654566 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -655,8 +655,9 @@ The practical next sequence is: 1. Add a cinematic/debug toggle and repeatable simulation-garden demo reset. 2. Expand resource discovery with many finite `ResourceNode` instances placed in meaningful foliage, animal-camp, berry, and village-stockpile contexts. - Score them by reachability, distance, safety, profession, and NPC comfort - range rather than reintroducing abstract resource zones. + The first distance/safety/comfort scoring pass exists; next, add more + authored resources and validate their navigation/reachability rather than + reintroducing abstract resource zones. 3. Expand persistence only when schedules, player state, or a real save menu creates a concrete requirement. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index c21b131..92e21b9 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -312,7 +312,9 @@ that amount, and releases an NPC reservation if the source is depleted. Future resource expansion should add many finite `ResourceNode` instances rather than broad resource zones: trees in foliage clusters, berry patches, animal camps, and village stockpiles can be ranked by reachability, distance, safety, -profession, and NPC comfort range. +profession, and NPC comfort range. The current resolver already combines +distance with resource `safety_risk`, `comfort_distance`, and +`discovery_priority` metadata. ### Jajce scaffold diff --git a/docs/RESOURCE_NODE_MIGRATION.md b/docs/RESOURCE_NODE_MIGRATION.md index 6bb9794..9559b48 100644 --- a/docs/RESOURCE_NODE_MIGRATION.md +++ b/docs/RESOURCE_NODE_MIGRATION.md @@ -542,11 +542,13 @@ compete for the same social/workstation site. **Exit:** temporary activity markers and task-to-marker mapping are deleted, not merely unused. -**Status: complete for the marker-removal migration.** Future work should -extend target scoring rather than reintroducing zones: resources can be spread +**Status: complete for the marker-removal migration.** Follow-up work now +extends target scoring rather than reintroducing zones: resources can be spread as many finite `ResourceNode` instances across foliage, animal camps, berry patches, and village stockpiles, then ranked by distance, reachability, safety, -profession, and NPC comfort range. +profession, and NPC comfort range. The first scoring pass stores +`safety_risk`, `comfort_distance`, and `discovery_priority` on resource state +and uses them when selecting NPC resource targets. ## Test scenarios diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index 4a8b7c7..a6ee588 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -74,8 +74,8 @@ duplicate IDs, and preserve deterministic ordering across save/restore. See `SimulationManager` owns `ResourceStateRecord` instances independently of the scene tree. Amount, reservation, enabled state, action/resource identity, -yield, and usage permissions remain available and serializable while no -ResourceNode is loaded. +yield, usage permissions, and discovery metadata remain available and +serializable while no ResourceNode is loaded. ResourceNode binds to a record by stable ID and provides presentation, interaction transforms, and active-world discoverability. Unloading a node does @@ -83,9 +83,10 @@ not delete its record. Loading another node with the same stable ID rebinds to the existing record and cannot overwrite its amount or reservation with scene defaults. -ResourceStateRecord v2 adds the definition facts needed while unloaded. Nested -v1 resource records are migrated explicitly and hydrate their missing -definition from a matching presentation node when one becomes available. +ResourceStateRecord v3 adds `safety_risk`, `comfort_distance`, and +`discovery_priority` so unloaded resources keep the same target-selection +meaning after save/restore. Nested v1 and v2 resource records are migrated +explicitly. ## Local quicksave boundary diff --git a/simulation/actions/ActionTargetResolver.gd b/simulation/actions/ActionTargetResolver.gd index fd63160..87f1fa3 100644 --- a/simulation/actions/ActionTargetResolver.gd +++ b/simulation/actions/ActionTargetResolver.gd @@ -30,16 +30,16 @@ func _resolve_resource( active_world_adapter: Node ) -> Dictionary: var best: Dictionary = {} - var best_distance := INF + var best_score := INF for candidate in active_world_adapter.get_resource_candidates(definition.resource_action_id): var node_id := StringName(candidate["target_id"]) var state: ResourceStateRecord = simulation_manager.get_resource_state(node_id) if state == null or not state.can_npc_use() or not state.is_available_for(npc.id): continue var position: Vector3 = candidate["position"] - var distance := origin.distance_squared_to(position) - if distance < best_distance: - best_distance = distance + var score := score_resource_candidate(npc, origin, position, state) + if score < best_score: + best_score = score best = candidate if best.is_empty(): return {} @@ -47,3 +47,17 @@ func _resolve_resource( if not simulation_manager.reserve_resource(target_id, npc.id): return {} return best + + +func score_resource_candidate( + npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord +) -> float: + var distance := origin.distance_to(position) + var comfort_overage := maxf(distance - state.get_comfort_distance(), 0.0) + var risk_weight := 20.0 + maxf(100.0 - npc.energy, 0.0) * 0.2 + return ( + distance + + comfort_overage * 2.5 + + state.get_safety_risk() * risk_weight + - state.get_discovery_priority() + ) diff --git a/simulation/state/ResourceStateRecord.gd b/simulation/state/ResourceStateRecord.gd index 02e095c..91c2884 100644 --- a/simulation/state/ResourceStateRecord.gd +++ b/simulation/state/ResourceStateRecord.gd @@ -4,8 +4,9 @@ extends RefCounted signal changed(state: ResourceStateRecord) signal depleted(state: ResourceStateRecord) -const SCHEMA_VERSION := 2 +const SCHEMA_VERSION := 3 const LEGACY_SCHEMA_VERSION := 1 +const DISCOVERY_LEGACY_SCHEMA_VERSION := 2 var data: Dictionary @@ -26,7 +27,10 @@ static func create_from_node(node: ResourceNode) -> ResourceStateRecord: "reserved_by": -1, "enabled": node.initial_enabled, "can_npcs_use": node.can_npcs_use, - "can_player_use": node.can_player_use + "can_player_use": node.can_player_use, + "safety_risk": node.safety_risk, + "comfort_distance": node.comfort_distance, + "discovery_priority": node.discovery_priority } ) @@ -35,6 +39,8 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord: var version := int(record_data.get("schema_version", -1)) if version == LEGACY_SCHEMA_VERSION: record_data = _migrate_v1(record_data) + elif version == DISCOVERY_LEGACY_SCHEMA_VERSION: + record_data = _migrate_v2(record_data) elif version != SCHEMA_VERSION: return null @@ -48,7 +54,10 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord: "reserved_by", "enabled", "can_npcs_use", - "can_player_use" + "can_player_use", + "safety_risk", + "comfort_distance", + "discovery_priority" ] ): return null @@ -70,6 +79,15 @@ static func _migrate_v1(legacy_data: Dictionary) -> Dictionary: migrated["yield_per_action"] = 0.0 migrated["can_npcs_use"] = false migrated["can_player_use"] = false + return _migrate_v2(migrated) + + +static func _migrate_v2(legacy_data: Dictionary) -> Dictionary: + var migrated := legacy_data.duplicate(true) + migrated["schema_version"] = SCHEMA_VERSION + migrated["safety_risk"] = clampf(float(migrated.get("safety_risk", 0.0)), 0.0, 1.0) + migrated["comfort_distance"] = maxf(float(migrated.get("comfort_distance", 18.0)), 0.1) + migrated["discovery_priority"] = float(migrated.get("discovery_priority", 0.0)) return migrated @@ -84,6 +102,9 @@ func apply_definition(node: ResourceNode) -> bool: 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["safety_risk"] = node.safety_risk + data["comfort_distance"] = node.comfort_distance + data["discovery_priority"] = node.discovery_priority data["schema_version"] = SCHEMA_VERSION return true @@ -132,6 +153,18 @@ func can_player_use_resource() -> bool: return bool(data["can_player_use"]) +func get_safety_risk() -> float: + return clampf(float(data["safety_risk"]), 0.0, 1.0) + + +func get_comfort_distance() -> float: + return maxf(float(data["comfort_distance"]), 0.1) + + +func get_discovery_priority() -> float: + return float(data["discovery_priority"]) + + func is_depleted() -> bool: return get_amount_remaining() <= 0.0 diff --git a/tests/action_system_boundaries_test.gd b/tests/action_system_boundaries_test.gd index df0bd12..fc96fd0 100644 --- a/tests/action_system_boundaries_test.gd +++ b/tests/action_system_boundaries_test.gd @@ -10,7 +10,7 @@ class FakeActiveWorld: func get_resource_candidates(_action_id: StringName) -> Array[Dictionary]: return resource_candidates - func get_activity_target(_action_id: StringName) -> Dictionary: + func get_activity_target(_action_id: StringName, _origin: Vector3 = Vector3.ZERO) -> Dictionary: return {"target_id": "", "position": activity_position} @@ -69,6 +69,7 @@ func _test_selection_and_execution_are_separate() -> void: func _test_target_resolution_and_travel_are_separate() -> void: var adapter := FakeActiveWorld.new() adapter.resource_candidates = [ + {"target_id": "boundary_risky_bush", "position": Vector3(1.0, 0.0, 0.0)}, {"target_id": "boundary_bush", "position": Vector3(3.0, 0.0, 2.0)} ] root.add_child(adapter) @@ -91,20 +92,42 @@ func _test_target_resolution_and_travel_are_separate() -> void: "reserved_by": -1, "enabled": true, "can_npcs_use": true, - "can_player_use": true + "can_player_use": true, + "safety_risk": 0.0, + "comfort_distance": 18.0, + "discovery_priority": 0.0 } ) manager.resource_states[resource_state.get_node_id()] = resource_state + var risky_resource_state := ResourceStateRecord.from_dictionary( + { + "schema_version": ResourceStateRecord.SCHEMA_VERSION, + "node_id": "boundary_risky_bush", + "action_id": String(SimulationIds.ACTION_GATHER_FOOD), + "resource_id": String(SimulationIds.RESOURCE_FOOD), + "amount_remaining": 5.0, + "yield_per_action": 1.0, + "reserved_by": -1, + "enabled": true, + "can_npcs_use": true, + "can_player_use": true, + "safety_risk": 1.0, + "comfort_distance": 18.0, + "discovery_priority": 0.0 + } + ) + manager.resource_states[risky_resource_state.get_node_id()] = risky_resource_state var npc: SimNPC = manager.npcs[0] + npc.energy = 40.0 npc.set_task(SimulationIds.ACTION_GATHER_FOOD) _check( manager.resolve_npc_target(npc.id, Vector3.ZERO), - "Target resolver should resolve a resource from adapter facts" + "Target resolver should resolve a scored resource from adapter facts" ) _check( npc.target_id == &"boundary_bush" and resource_state.get_reserved_by() == npc.id, - "Target resolver should authoritatively assign and reserve the target" + "Target resolver should prefer the safer comfortable resource over risky proximity" ) _check( travel_requests == [Vector3(3.0, 0.0, 2.0)], diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd index bd8e3b7..40dd0d8 100644 --- a/tests/food_storage_loop_test.gd +++ b/tests/food_storage_loop_test.gd @@ -27,7 +27,10 @@ func _run() -> void: "reserved_by": -1, "enabled": true, "can_npcs_use": true, - "can_player_use": true + "can_player_use": true, + "safety_risk": 0.0, + "comfort_distance": 18.0, + "discovery_priority": 0.0 } ) manager.resource_states[resource.get_node_id()] = resource diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index 16507f4..0b6f45f 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -116,6 +116,16 @@ func _run() -> void: ids == ["berry_bush_01", "berry_bush_02", "tree_01", "tree_02"], "Jajce resources should preserve all stable IDs" ) + var metadata_valid := true + for resource in resources: + var node := resource as ResourceNode + metadata_valid = ( + metadata_valid + and node.comfort_distance > 0.0 + and node.safety_risk >= 0.0 + and node.safety_risk <= 1.0 + ) + _check(metadata_valid, "Jajce resources should define discovery metadata") _check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites") var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode _check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode") diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index e728beb..a5ca5f5 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -156,6 +156,11 @@ func _test_legacy_resource_migration() -> void: is_equal_approx(migrated.get_amount_remaining(), 6.0) and migrated.get_reserved_by() == 12, "Resource migration should preserve mutable authority" ) + _check( + is_equal_approx(migrated.get_safety_risk(), 0.0) + and is_equal_approx(migrated.get_comfort_distance(), 18.0), + "Resource migration should default discovery metadata" + ) func _test_legacy_npc_migration() -> void: diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 9aa4447..2352d96 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -225,11 +225,17 @@ scale = Vector3(1.25, 1.25, 1.25) [node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(-6, 0, -8) node_id = &"berry_bush_01" +safety_risk = 0.05 +comfort_distance = 18.0 +discovery_priority = 1.0 [node name="BerryBush_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(-10, 0, -4) node_id = &"berry_bush_02" initial_amount = 8.0 +safety_risk = 0.08 +comfort_distance = 20.0 +discovery_priority = 0.5 [node name="Tree_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(6, 0, -8) @@ -238,6 +244,8 @@ action_id = &"gather_wood" resource_id = &"wood" initial_amount = 15.0 yield_per_action = 3.0 +safety_risk = 0.12 +comfort_distance = 24.0 [node name="Tree_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(10, 0, -4) @@ -246,6 +254,8 @@ action_id = &"gather_wood" resource_id = &"wood" initial_amount = 12.0 yield_per_action = 3.0 +safety_risk = 0.16 +comfort_distance = 26.0 [node name="StorageSites" type="Node3D" parent="WorldObjects"] diff --git a/world/resource_nodes/ResourceNode.gd b/world/resource_nodes/ResourceNode.gd index 5f6191b..770c1cc 100644 --- a/world/resource_nodes/ResourceNode.gd +++ b/world/resource_nodes/ResourceNode.gd @@ -15,6 +15,9 @@ static var _all: Array[ResourceNode] = [] @export var initial_enabled: bool = true @export var can_npcs_use: bool = true @export var can_player_use: bool = true +@export_range(0.0, 1.0, 0.05) var safety_risk := 0.0 +@export var comfort_distance := 18.0 +@export var discovery_priority := 0.0 @export var hide_visual_when_depleted: bool = false @onready var interaction_point: Marker3D = $InteractionPoint @@ -119,6 +122,8 @@ func _update_presentation() -> void: return var label := $DebugLabel as Label3D var text := "%s\n%s %.1f" % [node_id, resource_id, get_amount_remaining()] + if state != null: + text += "\nrisk:%.2f comfort:%.0f" % [state.get_safety_risk(), state.get_comfort_distance()] if get_reserved_by() >= 0: text += "\nheld:%d" % get_reserved_by() if is_depleted():