feat: score resource discovery by comfort and safety

This commit is contained in:
2026-07-07 15:10:06 +02:00
parent 4c090f1635
commit 036c9d5141
12 changed files with 132 additions and 23 deletions
+3 -2
View File
@@ -655,8 +655,9 @@ The practical next sequence is:
1. Add a cinematic/debug toggle and repeatable simulation-garden demo reset. 1. Add a cinematic/debug toggle and repeatable simulation-garden demo reset.
2. Expand resource discovery with many finite `ResourceNode` instances placed 2. Expand resource discovery with many finite `ResourceNode` instances placed
in meaningful foliage, animal-camp, berry, and village-stockpile contexts. in meaningful foliage, animal-camp, berry, and village-stockpile contexts.
Score them by reachability, distance, safety, profession, and NPC comfort The first distance/safety/comfort scoring pass exists; next, add more
range rather than reintroducing abstract resource zones. 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 3. Expand persistence only when schedules, player state, or a real save menu
creates a concrete requirement. creates a concrete requirement.
+3 -1
View File
@@ -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 Future resource expansion should add many finite `ResourceNode` instances rather
than broad resource zones: trees in foliage clusters, berry patches, animal than broad resource zones: trees in foliage clusters, berry patches, animal
camps, and village stockpiles can be ranked by reachability, distance, safety, 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 ### Jajce scaffold
+5 -3
View File
@@ -542,11 +542,13 @@ compete for the same social/workstation site.
**Exit:** temporary activity markers and task-to-marker mapping are deleted, **Exit:** temporary activity markers and task-to-marker mapping are deleted,
not merely unused. not merely unused.
**Status: complete for the marker-removal migration.** Future work should **Status: complete for the marker-removal migration.** Follow-up work now
extend target scoring rather than reintroducing zones: resources can be spread extends target scoring rather than reintroducing zones: resources can be spread
as many finite `ResourceNode` instances across foliage, animal camps, berry as many finite `ResourceNode` instances across foliage, animal camps, berry
patches, and village stockpiles, then ranked by distance, reachability, safety, 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 ## Test scenarios
+6 -5
View File
@@ -74,8 +74,8 @@ duplicate IDs, and preserve deterministic ordering across save/restore. See
`SimulationManager` owns `ResourceStateRecord` instances independently of the `SimulationManager` owns `ResourceStateRecord` instances independently of the
scene tree. Amount, reservation, enabled state, action/resource identity, scene tree. Amount, reservation, enabled state, action/resource identity,
yield, and usage permissions remain available and serializable while no yield, usage permissions, and discovery metadata remain available and
ResourceNode is loaded. serializable while no ResourceNode is loaded.
ResourceNode binds to a record by stable ID and provides presentation, ResourceNode binds to a record by stable ID and provides presentation,
interaction transforms, and active-world discoverability. Unloading a node does 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 the existing record and cannot overwrite its amount or reservation with scene
defaults. defaults.
ResourceStateRecord v2 adds the definition facts needed while unloaded. Nested ResourceStateRecord v3 adds `safety_risk`, `comfort_distance`, and
v1 resource records are migrated explicitly and hydrate their missing `discovery_priority` so unloaded resources keep the same target-selection
definition from a matching presentation node when one becomes available. meaning after save/restore. Nested v1 and v2 resource records are migrated
explicitly.
## Local quicksave boundary ## Local quicksave boundary
+18 -4
View File
@@ -30,16 +30,16 @@ func _resolve_resource(
active_world_adapter: Node active_world_adapter: Node
) -> Dictionary: ) -> Dictionary:
var best: 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): for candidate in active_world_adapter.get_resource_candidates(definition.resource_action_id):
var node_id := StringName(candidate["target_id"]) var node_id := StringName(candidate["target_id"])
var state: ResourceStateRecord = simulation_manager.get_resource_state(node_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): if state == null or not state.can_npc_use() or not state.is_available_for(npc.id):
continue continue
var position: Vector3 = candidate["position"] var position: Vector3 = candidate["position"]
var distance := origin.distance_squared_to(position) var score := score_resource_candidate(npc, origin, position, state)
if distance < best_distance: if score < best_score:
best_distance = distance best_score = score
best = candidate best = candidate
if best.is_empty(): if best.is_empty():
return {} return {}
@@ -47,3 +47,17 @@ func _resolve_resource(
if not simulation_manager.reserve_resource(target_id, npc.id): if not simulation_manager.reserve_resource(target_id, npc.id):
return {} return {}
return best 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()
)
+36 -3
View File
@@ -4,8 +4,9 @@ extends RefCounted
signal changed(state: ResourceStateRecord) signal changed(state: ResourceStateRecord)
signal depleted(state: ResourceStateRecord) signal depleted(state: ResourceStateRecord)
const SCHEMA_VERSION := 2 const SCHEMA_VERSION := 3
const LEGACY_SCHEMA_VERSION := 1 const LEGACY_SCHEMA_VERSION := 1
const DISCOVERY_LEGACY_SCHEMA_VERSION := 2
var data: Dictionary var data: Dictionary
@@ -26,7 +27,10 @@ static func create_from_node(node: ResourceNode) -> ResourceStateRecord:
"reserved_by": -1, "reserved_by": -1,
"enabled": node.initial_enabled, "enabled": node.initial_enabled,
"can_npcs_use": node.can_npcs_use, "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)) var version := int(record_data.get("schema_version", -1))
if version == LEGACY_SCHEMA_VERSION: if version == LEGACY_SCHEMA_VERSION:
record_data = _migrate_v1(record_data) record_data = _migrate_v1(record_data)
elif version == DISCOVERY_LEGACY_SCHEMA_VERSION:
record_data = _migrate_v2(record_data)
elif version != SCHEMA_VERSION: elif version != SCHEMA_VERSION:
return null return null
@@ -48,7 +54,10 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord:
"reserved_by", "reserved_by",
"enabled", "enabled",
"can_npcs_use", "can_npcs_use",
"can_player_use" "can_player_use",
"safety_risk",
"comfort_distance",
"discovery_priority"
] ]
): ):
return null return null
@@ -70,6 +79,15 @@ static func _migrate_v1(legacy_data: Dictionary) -> Dictionary:
migrated["yield_per_action"] = 0.0 migrated["yield_per_action"] = 0.0
migrated["can_npcs_use"] = false migrated["can_npcs_use"] = false
migrated["can_player_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 return migrated
@@ -84,6 +102,9 @@ func apply_definition(node: ResourceNode) -> bool:
data["yield_per_action"] = node.yield_per_action data["yield_per_action"] = node.yield_per_action
data["can_npcs_use"] = node.can_npcs_use data["can_npcs_use"] = node.can_npcs_use
data["can_player_use"] = node.can_player_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 data["schema_version"] = SCHEMA_VERSION
return true return true
@@ -132,6 +153,18 @@ func can_player_use_resource() -> bool:
return bool(data["can_player_use"]) 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: func is_depleted() -> bool:
return get_amount_remaining() <= 0.0 return get_amount_remaining() <= 0.0
+27 -4
View File
@@ -10,7 +10,7 @@ class FakeActiveWorld:
func get_resource_candidates(_action_id: StringName) -> Array[Dictionary]: func get_resource_candidates(_action_id: StringName) -> Array[Dictionary]:
return resource_candidates 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} 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: func _test_target_resolution_and_travel_are_separate() -> void:
var adapter := FakeActiveWorld.new() var adapter := FakeActiveWorld.new()
adapter.resource_candidates = [ 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)} {"target_id": "boundary_bush", "position": Vector3(3.0, 0.0, 2.0)}
] ]
root.add_child(adapter) root.add_child(adapter)
@@ -91,20 +92,42 @@ func _test_target_resolution_and_travel_are_separate() -> void:
"reserved_by": -1, "reserved_by": -1,
"enabled": true, "enabled": true,
"can_npcs_use": 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 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] var npc: SimNPC = manager.npcs[0]
npc.energy = 40.0
npc.set_task(SimulationIds.ACTION_GATHER_FOOD) npc.set_task(SimulationIds.ACTION_GATHER_FOOD)
_check( _check(
manager.resolve_npc_target(npc.id, Vector3.ZERO), 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( _check(
npc.target_id == &"boundary_bush" and resource_state.get_reserved_by() == npc.id, 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( _check(
travel_requests == [Vector3(3.0, 0.0, 2.0)], travel_requests == [Vector3(3.0, 0.0, 2.0)],
+4 -1
View File
@@ -27,7 +27,10 @@ func _run() -> void:
"reserved_by": -1, "reserved_by": -1,
"enabled": true, "enabled": true,
"can_npcs_use": 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 manager.resource_states[resource.get_node_id()] = resource
+10
View File
@@ -116,6 +116,16 @@ func _run() -> void:
ids == ["berry_bush_01", "berry_bush_02", "tree_01", "tree_02"], ids == ["berry_bush_01", "berry_bush_02", "tree_01", "tree_02"],
"Jajce resources should preserve all stable IDs" "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") _check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode
_check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode") _check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode")
@@ -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, is_equal_approx(migrated.get_amount_remaining(), 6.0) and migrated.get_reserved_by() == 12,
"Resource migration should preserve mutable authority" "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: func _test_legacy_npc_migration() -> void:
+10
View File
@@ -225,11 +225,17 @@ scale = Vector3(1.25, 1.25, 1.25)
[node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] [node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-6, 0, -8) position = Vector3(-6, 0, -8)
node_id = &"berry_bush_01" 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")] [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"
initial_amount = 8.0 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")] [node name="Tree_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(6, 0, -8) position = Vector3(6, 0, -8)
@@ -238,6 +244,8 @@ action_id = &"gather_wood"
resource_id = &"wood" resource_id = &"wood"
initial_amount = 15.0 initial_amount = 15.0
yield_per_action = 3.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")] [node name="Tree_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(10, 0, -4) position = Vector3(10, 0, -4)
@@ -246,6 +254,8 @@ action_id = &"gather_wood"
resource_id = &"wood" resource_id = &"wood"
initial_amount = 12.0 initial_amount = 12.0
yield_per_action = 3.0 yield_per_action = 3.0
safety_risk = 0.16
comfort_distance = 26.0
[node name="StorageSites" type="Node3D" parent="WorldObjects"] [node name="StorageSites" type="Node3D" parent="WorldObjects"]
+5
View File
@@ -15,6 +15,9 @@ static var _all: Array[ResourceNode] = []
@export var initial_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_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 @export var hide_visual_when_depleted: bool = false
@onready var interaction_point: Marker3D = $InteractionPoint @onready var interaction_point: Marker3D = $InteractionPoint
@@ -119,6 +122,8 @@ func _update_presentation() -> void:
return return
var label := $DebugLabel as Label3D var label := $DebugLabel as Label3D
var text := "%s\n%s %.1f" % [node_id, resource_id, get_amount_remaining()] 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: if get_reserved_by() >= 0:
text += "\nheld:%d" % get_reserved_by() text += "\nheld:%d" % get_reserved_by()
if is_depleted(): if is_depleted():