feat: enforce activity site capacity
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user