diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index d4d91f5..7e3ed9d 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -39,6 +39,7 @@ SimulationManager tick - consumes active-world facts through ActiveWorldAdapter; - checks simulation-owned ResourceStateRecord availability; - 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 presentation. @@ -46,6 +47,7 @@ SimulationManager tick - exposes loaded resource 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; - does not choose actions or reserve resources. diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index bf52664..d6dae2e 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -667,6 +667,9 @@ Recently completed: labels, and NPC name/profession labels without changing simulation state; - `F12` repeatable simulation-garden demo reset by reloading the current scene 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 progress suitable for public development updates. diff --git a/docs/RESOURCE_NODE_MIGRATION.md b/docs/RESOURCE_NODE_MIGRATION.md index a60e1e9..95437c6 100644 --- a/docs/RESOURCE_NODE_MIGRATION.md +++ b/docs/RESOURCE_NODE_MIGRATION.md @@ -534,10 +534,11 @@ transactions. Successful food transfers emit serializable economic events, and an active NPC visibly carries food between source, pantry, and consumption. Guard, study, and rest are now authored `ActivitySite` instances at -`JajceWorld/WorldObjects/ActivitySites`. The adapter chooses the nearest loaded -site that supports the requested action. Capacity is currently presentation -metadata; reservation/enforced occupancy can be added when multiple NPCs -compete for the same social/workstation site. +`JajceWorld/WorldObjects/ActivitySites`. The adapter exposes loaded activity +site candidates with capacity metadata, and `ActionTargetResolver` skips sites +whose current NPC target claims already meet that capacity. These claims are +derived from authoritative NPC target state rather than a presentation-only +counter. **Exit:** temporary activity markers and task-to-marker mapping are deleted, not merely unused. diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 9312815..2aaa339 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -395,6 +395,18 @@ func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool: 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: return StringName("npc_%d_inventory" % npc_id) diff --git a/simulation/actions/ActionTargetResolver.gd b/simulation/actions/ActionTargetResolver.gd index 87f1fa3..5f2b4cb 100644 --- a/simulation/actions/ActionTargetResolver.gd +++ b/simulation/actions/ActionTargetResolver.gd @@ -14,7 +14,7 @@ func resolve( npc, origin, definition, simulation_manager, active_world_adapter ) 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: return { "target_id": "", "position": origin + simulation_manager.get_wander_offset(npc.id) @@ -49,6 +49,36 @@ func _resolve_resource( 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( npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord ) -> float: diff --git a/tests/action_system_boundaries_test.gd b/tests/action_system_boundaries_test.gd index fc96fd0..99958df 100644 --- a/tests/action_system_boundaries_test.gd +++ b/tests/action_system_boundaries_test.gd @@ -6,6 +6,7 @@ class FakeActiveWorld: var resource_candidates: Array[Dictionary] = [] var activity_position := Vector3(4.0, 0.0, 7.0) + var activity_candidates: Array[Dictionary] = [] func get_resource_candidates(_action_id: StringName) -> Array[Dictionary]: return resource_candidates @@ -13,6 +14,9 @@ class FakeActiveWorld: func get_activity_target(_action_id: StringName, _origin: Vector3 = Vector3.ZERO) -> Dictionary: return {"target_id": "", "position": activity_position} + func get_activity_candidates(_action_id: StringName) -> Array[Dictionary]: + return activity_candidates + var failures: Array[String] = [] var travel_requests: Array[Vector3] = [] @@ -135,14 +139,22 @@ func _test_target_resolution_and_travel_are_separate() -> void: ) 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) _check( manager.resolve_npc_target(npc.id, Vector3.ZERO), "Target resolver should resolve an activity through the adapter" ) _check( - travel_requests.back() == adapter.activity_position, - "Activity resolution should publish the adapter's position" + npc.target_id == &"study_desk_open" and travel_requests.back() == adapter.activity_position, + "Activity resolution should skip a full-capacity site and publish the open site's position" ) manager.free() adapter.free() diff --git a/world/active_world_adapter.gd b/world/active_world_adapter.gd index c938de4..5aeae70 100644 --- a/world/active_world_adapter.gd +++ b/world/active_world_adapter.gd @@ -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: + 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: - 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: return _get_storage_target(pantry_storage) SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_WITHDRAW_FOOD: @@ -37,25 +41,22 @@ func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) return {} -func _get_activity_site_target(action_id: StringName, origin: Vector3) -> Dictionary: - var best_site: ActivitySite - var best_distance := INF +func get_activity_candidates(action_id: StringName) -> Array[Dictionary]: + var candidates: Array[Dictionary] = [] for candidate in ActivitySite.get_all(): var site := candidate as ActivitySite if site == null: continue if not site.supports_action(action_id): continue - var distance := origin.distance_squared_to(site.get_interaction_position()) - if distance < best_distance: - best_distance = distance - best_site = site - if best_site == null: - return {} - return { - "target_id": String(best_site.site_id), - "position": best_site.get_interaction_position() - } + candidates.append( + { + "target_id": String(site.site_id), + "position": site.get_interaction_position(), + "capacity": site.capacity + } + ) + return candidates func _get_storage_target(storage_node: StorageNode) -> Dictionary: