Files
EscapistsReintroduced/docs/living-island/T2-presence-budget.md
T
jakub cab998fb64 Combine Workshop addon and fix prison startup
Merge owned systems into one addon while preserving resource identities. Gate player admission on the replicated prison scene, restore the original framed door, and hold enemy AI until escape. Correct scenario branding and author metadata, include publishing assets, and retain regression and native verification evidence.
2026-09-20 16:42:52 +02:00

4.5 KiB

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_AIGroups 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<vector>). 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.