# T2 — Presence budget: materialise few, simulate many Read `docs/living-island/README.md` first, including the rules section. Depends on T1. ## Goal Decide, on a slow tick, which ledger records are physical `SCR_AIGroup`s right now — under a hard agent budget, without thrashing, and without the player ever seeing a unit appear or vanish in view. ## What to build `addons/core/Scripts/Game/ERR/ERR_PresenceBudget.c`, again split into a pure rule class and a service: ``` class ERR_PresenceRules { static bool WantsPhysical(float nearestObserver, bool currentlyPhysical, float spawnBand, float despawnBand); // hysteresis static bool DwellSatisfied(float now, float stateSince, float minimumDwell); static int Score(ERR_ForceKind kind, int importance, float nearestObserver, bool pinned); static bool WithinBudget(int activeAgents, int requestedAgents, int budget); } class ERR_PresenceBudget { ... } ``` ## Required behaviour - **Observers:** use `ObserversSystem.GetObservers(out array)`. Bucket observers into 500 m cells (copy the pattern from `ERR_HQNetwork.Add/Build`); never scan all records against all observers. - **Hysteresis:** spawn band 600 m, despawn band 900 m (settings `presenceSpawnMetres`, `presenceDespawnMetres`). A record inside 600 m wants to be physical; it only goes dormant past 900 m. - **Dwell:** minimum 30 s in a state before it may flip (`presenceDwellSeconds`). - **Budget:** `activeAgentBudget`, default 60. Count real agents, not records. When a spawn would exceed it, refuse and log — do not spawn and hope. - **Eviction:** when over budget, dormant the lowest-scoring physical records first (farthest, least important, not pinned). Never evict a record that is in combat (`SCR_AIGroupUtilityComponent.m_Perception.m_aTargets` non-empty), that contains a player, or that is pinned by HQ. - **Pinning:** expose `Pin(int recordId, bool pinned)` so T5 can force a record physical while HQ has it tasked near players. - **Never despawn in view:** if any observer is within the despawn band, the record stays physical, full stop. The dwell timer does not override this. ## Materialisation - Physical: create or re-use the group, `SetImportance(...)` from the record's tier, `SetLifecyclePolicy(SCR_EAIGroupLifecyclePolicy.Manual, ...)` — **we** drive spawn/despawn, not the engine's proximity policy, because the ledger is the source of truth. - Place the group at `ERR_Actors.Ground(record.Position)` — this is the only place ground height is resolved. - Dormant: `DespawnMembers()`. The group entity stays alive and remembers alive and dead counts, so re-materialising restores the right strength. Write the surviving strength back into the record. - Casualties taken while physical must persist into the record, so a mauled patrol stays mauled after going dormant. - Set `SetEliminateWhenReached(true)` on ambient patrol records so that if a player walks onto a record that could not spawn because the budget was full, the area counts as cleared instead of the patrol popping in later. Hook `GetOnEliminatedWhenReached()` to retire the record. ## Acceptance criteria 1. With a 60-agent budget and 30 records of 4 soldiers each, active agents never exceed 60, and `Status()` reports refusals. 2. A player walking a straight line past three patrol routes sees each patrol materialise before it is visible and never sees one disappear. 3. A patrol that loses two members while physical comes back with the reduced strength after a dormant period. 4. No record flips state more than once per 30 s in a stationary-observer test. 5. `python3 tools/check.py` passes. ## Tests to write `tests/test_presence_budget.py`: - `WantsPhysical` hysteresis: a record at 700 m stays in whichever state it is already in; crossing 600 m spawns; crossing 900 m dormants. - `DwellSatisfied` blocks a flip at 29 s and allows it at 31 s. - `WithinBudget` refuses the spawn that would cross the cap, and a sequence of 20 spawn requests against a 60 budget with 4-man groups accepts exactly 15. - `Score` ranks a pinned distant record above an unpinned near one, and ranks importance above distance for equal pinning. ## Do not - Do not use `ProximityDriven` lifecycle for ledger records — it would let the engine fight the ledger over who owns the decision. - Do not delete group entities to go dormant; `DespawnMembers()` is the mechanism that preserves strength. - Do not add a per-frame check for "is the player looking at it". Distance bands plus dwell are sufficient and cheap.