feat: enforce activity site capacity

This commit is contained in:
2026-07-07 16:12:13 +02:00
parent 9d87372a15
commit 1c389ec23d
7 changed files with 87 additions and 26 deletions
+2
View File
@@ -39,6 +39,7 @@ SimulationManager tick
- consumes active-world facts through ActiveWorldAdapter; - consumes active-world facts through ActiveWorldAdapter;
- checks simulation-owned ResourceStateRecord availability; - checks simulation-owned ResourceStateRecord availability;
- reserves and returns stable resource target IDs; - reserves and returns stable resource target IDs;
- skips full-capacity activity sites based on authoritative NPC target claims;
- returns typed activity-site, storage, or wander positions without owning - returns typed activity-site, storage, or wander positions without owning
presentation. presentation.
@@ -46,6 +47,7 @@ SimulationManager tick
- exposes loaded resource interaction positions; - exposes loaded resource interaction positions;
- exposes loaded storage and activity-site interaction positions; - exposes loaded storage and activity-site interaction positions;
- exposes activity-site capacity as an active-world fact;
- contains active-world query facts, not persistent mutable authority; - contains active-world query facts, not persistent mutable authority;
- does not choose actions or reserve resources. - does not choose actions or reserve resources.
+3
View File
@@ -667,6 +667,9 @@ Recently completed:
labels, and NPC name/profession labels without changing simulation state; labels, and NPC name/profession labels without changing simulation state;
- `F12` repeatable simulation-garden demo reset by reloading the current scene - `F12` repeatable simulation-garden demo reset by reloading the current scene
with the same authored seed and starting state. with the same authored seed and starting state.
- Activity-site capacity enforcement for rest, study, and patrol target
resolution, derived from NPC target claims rather than presentation-only
counters.
This order strengthens the simulation while regularly producing visible This order strengthens the simulation while regularly producing visible
progress suitable for public development updates. progress suitable for public development updates.
+5 -4
View File
@@ -534,10 +534,11 @@ transactions. Successful food transfers emit serializable economic events, and
an active NPC visibly carries food between source, pantry, and consumption. an active NPC visibly carries food between source, pantry, and consumption.
Guard, study, and rest are now authored `ActivitySite` instances at Guard, study, and rest are now authored `ActivitySite` instances at
`JajceWorld/WorldObjects/ActivitySites`. The adapter chooses the nearest loaded `JajceWorld/WorldObjects/ActivitySites`. The adapter exposes loaded activity
site that supports the requested action. Capacity is currently presentation site candidates with capacity metadata, and `ActionTargetResolver` skips sites
metadata; reservation/enforced occupancy can be added when multiple NPCs whose current NPC target claims already meet that capacity. These claims are
compete for the same social/workstation site. derived from authoritative NPC target state rather than a presentation-only
counter.
**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.
+12
View File
@@ -395,6 +395,18 @@ func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool:
return false return false
func get_activity_target_claim_count(target_id: StringName, except_npc_id: int = -1) -> int:
var count := 0
for npc in npcs:
if npc.id == except_npc_id or npc.is_dead:
continue
if npc.target_id != target_id:
continue
if npc.task_state in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
count += 1
return count
func _npc_inventory_id(npc_id: int) -> StringName: func _npc_inventory_id(npc_id: int) -> StringName:
return StringName("npc_%d_inventory" % npc_id) return StringName("npc_%d_inventory" % npc_id)
+31 -1
View File
@@ -14,7 +14,7 @@ func resolve(
npc, origin, definition, simulation_manager, active_world_adapter npc, origin, definition, simulation_manager, active_world_adapter
) )
SimulationIds.TARGET_ACTIVITY: SimulationIds.TARGET_ACTIVITY:
return active_world_adapter.get_activity_target(npc.current_task, origin) return _resolve_activity(npc, origin, simulation_manager, active_world_adapter)
SimulationIds.TARGET_FREE: SimulationIds.TARGET_FREE:
return { return {
"target_id": "", "position": origin + simulation_manager.get_wander_offset(npc.id) "target_id": "", "position": origin + simulation_manager.get_wander_offset(npc.id)
@@ -49,6 +49,36 @@ func _resolve_resource(
return best return best
func _resolve_activity(
npc: SimNPC, origin: Vector3, simulation_manager: Node, active_world_adapter: Node
) -> Dictionary:
if not active_world_adapter.has_method("get_activity_candidates"):
return active_world_adapter.get_activity_target(npc.current_task, origin)
var best: Dictionary = {}
var best_distance := INF
var candidates: Array[Dictionary] = active_world_adapter.get_activity_candidates(npc.current_task)
for candidate in candidates:
var target_id := StringName(candidate["target_id"])
var capacity := maxi(int(candidate.get("capacity", 1)), 1)
if (
simulation_manager.has_method("get_activity_target_claim_count")
and simulation_manager.get_activity_target_claim_count(target_id, npc.id) >= capacity
):
continue
var position: Vector3 = candidate["position"]
var distance := origin.distance_squared_to(position)
if distance < best_distance:
best_distance = distance
best = candidate
if not best.is_empty():
return best
if not candidates.is_empty():
return {}
return active_world_adapter.get_activity_target(npc.current_task, origin)
func score_resource_candidate( func score_resource_candidate(
npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord
) -> float: ) -> float:
+14 -2
View File
@@ -6,6 +6,7 @@ class FakeActiveWorld:
var resource_candidates: Array[Dictionary] = [] var resource_candidates: Array[Dictionary] = []
var activity_position := Vector3(4.0, 0.0, 7.0) var activity_position := Vector3(4.0, 0.0, 7.0)
var activity_candidates: Array[Dictionary] = []
func get_resource_candidates(_action_id: StringName) -> Array[Dictionary]: func get_resource_candidates(_action_id: StringName) -> Array[Dictionary]:
return resource_candidates return resource_candidates
@@ -13,6 +14,9 @@ class FakeActiveWorld:
func get_activity_target(_action_id: StringName, _origin: Vector3 = Vector3.ZERO) -> 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}
func get_activity_candidates(_action_id: StringName) -> Array[Dictionary]:
return activity_candidates
var failures: Array[String] = [] var failures: Array[String] = []
var travel_requests: Array[Vector3] = [] var travel_requests: Array[Vector3] = []
@@ -135,14 +139,22 @@ func _test_target_resolution_and_travel_are_separate() -> void:
) )
manager.release_npc_reservation(npc.id) manager.release_npc_reservation(npc.id)
adapter.activity_candidates = [
{"target_id": "study_desk_full", "position": Vector3(1.0, 0.0, 0.0), "capacity": 1},
{"target_id": "study_desk_open", "position": Vector3(4.0, 0.0, 7.0), "capacity": 1}
]
var occupying_npc: SimNPC = manager.npcs[1]
occupying_npc.set_task(SimulationIds.ACTION_STUDY)
occupying_npc.target_id = &"study_desk_full"
occupying_npc.task_state = SimNPC.TASK_STATE_WORKING
npc.set_task(SimulationIds.ACTION_STUDY) npc.set_task(SimulationIds.ACTION_STUDY)
_check( _check(
manager.resolve_npc_target(npc.id, Vector3.ZERO), manager.resolve_npc_target(npc.id, Vector3.ZERO),
"Target resolver should resolve an activity through the adapter" "Target resolver should resolve an activity through the adapter"
) )
_check( _check(
travel_requests.back() == adapter.activity_position, npc.target_id == &"study_desk_open" and travel_requests.back() == adapter.activity_position,
"Activity resolution should publish the adapter's position" "Activity resolution should skip a full-capacity site and publish the open site's position"
) )
manager.free() manager.free()
adapter.free() adapter.free()
+20 -19
View File
@@ -23,13 +23,17 @@ func get_resource_candidates(action_id: StringName) -> Array[Dictionary]:
func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) -> Dictionary: func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) -> Dictionary:
var best_candidate: Dictionary = {}
var best_distance := INF
for candidate in get_activity_candidates(action_id):
var position: Vector3 = candidate["position"]
var distance := origin.distance_squared_to(position)
if distance < best_distance:
best_distance = distance
best_candidate = candidate
if not best_candidate.is_empty():
return best_candidate
match action_id: match action_id:
SimulationIds.ACTION_PATROL:
return _get_activity_site_target(action_id, origin)
SimulationIds.ACTION_STUDY:
return _get_activity_site_target(action_id, origin)
SimulationIds.ACTION_REST:
return _get_activity_site_target(action_id, origin)
SimulationIds.ACTION_EAT: SimulationIds.ACTION_EAT:
return _get_storage_target(pantry_storage) return _get_storage_target(pantry_storage)
SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_WITHDRAW_FOOD: SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_WITHDRAW_FOOD:
@@ -37,25 +41,22 @@ func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO)
return {} return {}
func _get_activity_site_target(action_id: StringName, origin: Vector3) -> Dictionary: func get_activity_candidates(action_id: StringName) -> Array[Dictionary]:
var best_site: ActivitySite var candidates: Array[Dictionary] = []
var best_distance := INF
for candidate in ActivitySite.get_all(): for candidate in ActivitySite.get_all():
var site := candidate as ActivitySite var site := candidate as ActivitySite
if site == null: if site == null:
continue continue
if not site.supports_action(action_id): if not site.supports_action(action_id):
continue continue
var distance := origin.distance_squared_to(site.get_interaction_position()) candidates.append(
if distance < best_distance: {
best_distance = distance "target_id": String(site.site_id),
best_site = site "position": site.get_interaction_position(),
if best_site == null: "capacity": site.capacity
return {} }
return { )
"target_id": String(best_site.site_id), return candidates
"position": best_site.get_interaction_position()
}
func _get_storage_target(storage_node: StorageNode) -> Dictionary: func _get_storage_target(storage_node: StorageNode) -> Dictionary: