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.
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.
+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
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
+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,
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
+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
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
+18 -4
View File
@@ -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()
)
+36 -3
View File
@@ -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
+27 -4
View File
@@ -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)],
+4 -1
View File
@@ -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
+10
View File
@@ -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")
@@ -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:
+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")]
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"]
+5
View File
@@ -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():