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.
This commit is contained in:
2026-09-20 16:42:52 +02:00
parent 7d94953b53
commit cab998fb64
513 changed files with 18940 additions and 862 deletions
+431
View File
@@ -0,0 +1,431 @@
# Island occupation — agent brief
**Hand this file to a coding agent as-is.** It is self-contained: concept, rules,
verified engine APIs, work breakdown, acceptance criteria and tests.
Companion files (same directory) hold the supporting infrastructure this design
sits on: `README.md` (plan and rules), `T1-force-ledger.md`,
`T2-presence-budget.md`, `T3-checkpoints.md`, `T4-smart-actions.md`,
`T5-hq-integration.md`, `T6-ambient-signals.md`, `T7-performance.md`.
If the ledger (T1) and presence budget (T2) are not yet implemented, build those
first — this design assumes them.
---
## 1. The concept
The island does **not** start occupied. At mission start only the prison and its
immediate surroundings hold troops. Everything else — the radio station, the
helipad, the junctions, the depots — is empty ground with buildings on it.
Then the alarm goes off, and the enemy *moves in while the player watches*.
Helicopters lift sections out to guard the objectives. Trucks drive to junctions
and the soldiers get out and **build roadblocks over several minutes**. Patrols
fan out along the roads. Over the next twenty to forty minutes the island closes
around the player.
This gives three things at once:
1. **Atmosphere.** Watching a transport helicopter flare and unload eight
soldiers onto the hill you were about to cross is worth more than finding
those eight soldiers already standing there.
2. **CPU.** Almost nothing exists at T+0. Deployments travel as ledger records
and only become real AI near players. Start cost is near zero, and the
steady-state cost is the same agent budget as before.
3. **Counterplay and pressure.** Every deployment is interruptible, every loss
is permanent, and an island left alone gets steadily harder to cross. Hiding
is punished; moving fast is rewarded.
**The player must be able to change the outcome.** Shoot down the transport and
those eight soldiers never arrive — that junction stays open for the rest of the
round. That is the whole point of the design; protect it in every decision.
---
## 2. Non-negotiable rules
### Licensing
This project is an independent successor. The original Escapists mod and its
`ScrtCore` / `ScrtSharedContent` dependencies are SCRT-ND licensed (an APL-ND
variant) which **forbids derivative works**.
- Never copy code, configs, layouts, compositions or assets from the original
Escapists mod, from ScrtCore/ScrtSharedContent, or from `/home/jakub/devel/escapists`.
- Never introduce identifiers matching `ESCT*`, `SCRTC_*`, `SCRT_*`, `EAI_*`.
`tools/check.py:39` fails the build on these.
- Only vanilla Arma Reforger resources and resources owned by this repo may be
referenced. Reimplementing a mechanic from scratch is fine; copying an
implementation is not.
### Addon boundaries
Five addons only: `core`, `ai`, `intel`, `chat`, `map`. `ai`/`intel`/`map`
depend on `core`; `chat` depends on all. **`core` must never reference `ERR_HQ`,
`ERR_AircraftService`, or anything else from another addon.**
Occupation logic lives in **`core`**, so the base scenario occupies the island
without the AI addon installed. Where it needs a companion capability it defines
an interface in core and lets `ai` register an implementation — the existing
`ERR_CommandService` and `ERR_AdminProvider` show the pattern. Without the AI
addon, occupation still happens by road and on foot; only air lift is missing.
### Performance
1. No per-frame work. Every tick here is ≥ 1 s, staggered by id (`id % N`).
2. No allocation inside loops. Reuse member arrays.
3. Spatial bucketing (500 m cells) for proximity — copy `ERR_HQNetwork.Add/Build`.
4. Hard caps everywhere, with a logged refusal when hit.
5. Issue waypoints and let the engine drive. No custom per-frame steering.
6. Hysteresis and dwell before any spawn/dormant flip.
### Style and validation
- Match surrounding indentation (older files use tabs). New lines under 120 chars.
- Decision logic goes in **pure static rule classes** (see `ERR_MissionRules`,
`ERR_DiscoveryMapRules`) so it is testable without the engine. Entity work
stays in the service.
- `python3 tools/check.py` must pass before you report done.
- Add tests with `tests/policy_harness.py`: `body(path, signature)` extracts a
real function body from the `.c` file and `run(code)` compiles and runs it as
C++. Follow `tests/test_admin_session.py`.
- Report honestly. Separate "compiles and passes scripted fixtures" from
"verified in the real game". Do not write evidence claiming runtime
verification you did not perform.
---
## 3. The occupation timeline
### T+0 — Dormant island
| Location | State at start |
| --- | --- |
| Prison | Full garrison, physical (players are there) |
| Within 2 km of prison | 2 patrol records, dormant |
| Staging areas (1–2 authored) | The entire remaining force pool, as records |
| Everything else | Buildings and props only. **No troops.** |
Authored staging areas are the military sites the occupation flows out of — pick
the existing barracks/airfield areas on Everon. They hold the pool and are where
transport originates. A staging area is itself a site that can be attacked.
### T+alarm — Detection
The occupation plan starts when HQ learns something is wrong, through the
existing systems, not a timer: an escape is witnessed, a body or breach is found,
or a guard check-in is missed. If nothing is detected, occupation still begins
on a long fallback (default 15 min, setting `occupationFallbackSeconds`) — a
missing prisoner count is noticed eventually.
### T+alarm onward — Deployment
HQ walks a priority list and issues deployment orders while the pool lasts:
| Priority | Objective | Why |
| --- | --- | --- |
| 1 | Radio station(s) | Denies the player's first objective |
| 2 | Helipad / extraction site | Denies the escape |
| 3 | Road junctions and bridges on the ring road | Roadblocks, cordon |
| 4 | Medical and depot sites | Denies resupply |
| 5 | Overwatch positions near likely routes | Observation, HQ eyes |
Cadence: one order every 90–180 s early, slowing as the pool depletes
(`occupationOrderSeconds`). At most **5 deployments in transit at once**.
### T+20 to T+40 — Entrenchment
Arrived sections build. Roadblocks go up over 6–10 minutes each. Checkpoints
become manned (T3). Patrol routes establish between occupied sites. The island
that was empty is now a network.
### Continuous — Adaptation
As HQ gains contacts (T5), it re-prioritises: the sector where players were seen
gets the next deployments and a cordon on its exits; quiet sectors are thinned,
not reinforced. With no contacts, occupation proceeds methodically down the
priority list — so hiding tightens the noose.
---
## 4. Data model
Add to `core`, next to the ledger:
```
class ERR_ForcePool // finite, permanent losses
{
int Sections; // remaining uncommitted sections
int SectionStrength; // soldiers per section, default 4
int TransportAircraft; // remaining flyable transports
int TransportVehicles; // remaining trucks
}
enum ERR_OccupationPriority { RADIO, EXTRACTION, JUNCTION, SUPPLY, OVERWATCH }
class ERR_OccupationObjective
{
string SiteId; ERR_OccupationPriority Priority;
int RequiredSections; int AssignedSections;
bool Held; bool Contested; bool Abandoned;
}
enum ERR_DeployMethod { FOOT, CONVOY, AIRLIFT }
enum ERR_DeployState { QUEUED, LOADING, IN_TRANSIT, UNLOADING, ARRIVED, LOST }
class ERR_DeploymentOrder
{
int Id; string ObjectiveId; ERR_DeployMethod Method; ERR_DeployState State;
int LedgerRecordId; // the travelling force (T1 record)
int TransportRecordId; // the aircraft/vehicle, -1 for foot
vector Origin; vector LandingZone; float StartedAt; float EtaSeconds;
}
```
`ERR_OccupationPlan` owns the pool, the objective list and the live orders, and
ticks every 5 s (staggered). It is created and ticked by `ERR_GameMode` on the
existing tick path — **do not add a timer**.
---
## 5. Deployment methods
Method selection is a pure rule (`ERR_DeployRules.Select`): urgency, distance,
and what transport remains. Roughly — under 1.5 km on foot, 1.5–6 km by convoy,
over 6 km or urgent by air, falling back down the list when transport is gone or
the AI addon is absent.
### Foot
The record's duty becomes `MOVE_TO`; it walks its route virtually and
materialises near players like anything else. No transport consumed.
### Convoy
Consumes a truck. A `CONVOY` record carries the section along an authored road
route. Slow (8–15 min) and ambushable — when physical, it is a real truck with
real soldiers in it. If destroyed, the section and the truck are **permanently**
lost from the pool.
### Air lift
Consumes an aircraft. Three rules make this affordable — read them before
implementing, because the naive version blows the whole agent budget at T+0.
**Cargo is virtual in transit.** Passengers inside a fuselage are invisible.
Never spawn them for the flight. A physical lift is **1 vehicle + 1 pilot**; the
section spawns at the moment of unload. This takes a lift from ~10 agents to ~2
with no visual difference, and it is the single most important optimisation in
this document.
**Two aircraft in the air at once, not ten.** Queue as many lifts as the plan
needs — the island-wide occupation is real — but execute at most
`occupationConcurrentLifts` (default 2) physically. The rest fly as records with
real routes and real timings, and materialise if a player comes near. The player
is only ever in one place; ten simultaneous flights pay full price for drama
nobody witnesses. The existing `ERR_AircraftService.MAX_AIRCRAFT` is 4, and the
controller's demonstrated envelope is vertical flight plus a bounded 20 m route —
ten concurrent cross-map flights with landings is far outside what is proven.
Treat the concurrency as a dial: start at 2, measure with the T7 metrics, raise
it only if the numbers allow.
**Aircraft get a far larger materialisation band than infantry.** A helicopter is
visible and audible at 2–3 km, so lifts materialise at `presenceAirMetres`
(default 2500 m) instead of the 600 m used for foot patrols. This is what
actually delivers "helicopters moving around the island" — they are seen
crossing the valley, not only overhead.
A fleet of ten airframes in the pool is fine; ten in the air simultaneously is
not. Note also that ten AI helicopters converging on one staging area is where
collisions, failed landings and wrecks blocking your own LZs come from.
Core cannot call the aircraft service directly, so:
```
// core
class ERR_TransportProvider // interface
{
bool CanLift(vector from, vector to);
int Lift(int recordId, vector from, vector landingZone, float deadline);
int LiftState(int orderId); // queued / flying / landed / unloaded / lost
void CancelLift(int orderId);
}
```
`core` calls the registered provider; `ai` registers an implementation backed by
the existing `ERR_AircraftService` (`addons/ai/Scripts/Game/ERR/ERR_AircraftService.c`),
which already supports route flight, field flight, vertical flight, cancellation
and a `MAX_AIRCRAFT` cap of 4. **Reuse it — do not write a second flight
controller.** `{859B2A6A7DFA9EFB}` style GUIDs must come from
`docs/vanilla-resources.json`; the transport helicopter
`Prefabs/Vehicles/Helicopters/Mi8MT/Mi8MT_unarmed_transport.et` is already
registered there.
Lift sequence — each stage is a state, none of it is per-frame:
1. `LOADING` — section boards at the staging area (virtual unless observed).
2. `IN_TRANSIT` — aircraft flies the route. Physical when near observers; this
is the moment the design exists for, so let it be seen and heard.
3. `UNLOADING` — lands at the LZ, section disembarks, order is marked `ARRIVED`.
4. Aircraft returns to staging and is returned to the pool.
**LZ selection:** flat ground within 150 m of the objective, not inside a
building footprint, not on the road. Choose from authored LZ points per site
rather than searching terrain at runtime — authoring is cheaper and more
reliable than a runtime flatness search.
**Interruption:** if the aircraft is destroyed in any state before `ARRIVED`,
the section and the aircraft are permanently removed from the pool, the
objective returns to unassigned with a logged reason, and HQ must re-plan.
Same for a convoy. This must be tested, not assumed.
---
## 6. Roadblock construction
A roadblock is built, not spawned. State machine, advancing on the 5 s plan tick:
| State | What exists | Duration |
| --- | --- | --- |
| `SURVEYED` | Nothing. Site reserved by an incoming order | — |
| `MATERIEL` | Flat-packed props: stacked barriers, crates, a parked truck | on arrival |
| `BUILDING` | Props appear in 3 stages as progress crosses 33/66/100% | 6–10 min |
| `MANNED` | Complete; becomes a T3 checkpoint with its garrison | — |
| `DAMAGED` | Partial props, reduced garrison | on attack |
| `ABANDONED` | Props remain, no garrison, never repopulated | garrison wiped |
Rules:
- Progress advances **whether or not anyone is watching**, and props spawn on
stage transitions regardless of observers. A player who leaves and returns
finds a finished roadblock. Props are static and cost nothing.
- Soldiers under construction use T4 smart actions so they look like they are
working. Never all of them — keep a sentry.
- Destroying props during `BUILDING` drops progress and can return the site to
`MATERIEL`. Destroying it repeatedly must not loop forever: after two resets
HQ abandons that junction and spends the section elsewhere.
- Leave a drivable gap. A roadblock that fully seals a road can soft-lock a
route; it should slow and expose the player, not wall them in.
- At most **4 construction sites at once** (`occupationBuildSites`).
---
## 7. Interaction with HQ and fairness
- Dormant records **never observe and never report**. Only physical units
generate contacts. Enforce in code.
- Deployment orders are HQ traffic: they travel the existing radio model
(`ERR_HQNetwork`) with its latency and failure rules. Severing HQ's radio chain
delays or loses deployment orders — killing radio operators slows the
occupation, which is exactly the intended counterplay.
- HQ's contact-driven re-prioritisation reuses the scored tasking from T5. The
occupation plan proposes; HQ's priorities re-order the queue.
---
## 8. Caps (all settings, all enforced, all logged on refusal)
| Setting | Default | Meaning |
| --- | --- | --- |
| `activeAgentBudget` | 60 | Global active enemy agents (T2 owns enforcement) |
| `occupationSections` | 16 | Total sections in the pool |
| `occupationTransportAir` | 2 | Transport aircraft |
| `occupationTransportRoad` | 3 | Trucks |
| `occupationConcurrentOrders` | 5 | Deployments in transit at once |
| `occupationConcurrentLifts` | 2 | Air lifts flown **physically** at once; others fly as records |
| `presenceAirMetres` | 2500 | Materialisation band for aircraft (infantry use 600) |
| `occupationBuildSites` | 4 | Simultaneous construction sites |
| `occupationOrderSeconds` | 120 | Cadence between new orders |
| `occupationFallbackSeconds` | 900 | Alarm fallback if nothing is detected |
---
## 9. Work breakdown
Implement in order. One agent per step is fine; steps O3 and O4 can run in
parallel after O2.
- **O1 — Pool, objectives and plan skeleton.** `ERR_ForcePool`,
`ERR_OccupationObjective`, `ERR_OccupationPlan` with the priority queue and the
5 s tick. Authored staging areas and objective list in `EveronSites.conf`.
Start state: prison garrison only, everything else empty.
- **O2 — Deployment orders and transit.** `ERR_DeploymentOrder`,
`ERR_DeployRules.Select`, foot and virtual transit, arrival handover to the
objective. Permanent pool loss on destruction.
- **O3 — Air lift.** The `ERR_TransportProvider` interface in core, the `ai`
implementation over `ERR_AircraftService`, authored LZ points, load/fly/unload
states, loss handling.
- **O4 — Convoy.** Road routes, truck + section as a `CONVOY` record, ambush and
loss handling, blocked-route fallback to foot.
- **O5 — Roadblock construction.** The state machine, staged props, progress,
damage and abandonment, handover to T3 checkpoints.
- **O6 — Adaptation and proof.** Contact-driven re-prioritisation, cordon
formation, plus the metrics and the measured comparison run from T7.
---
## 10. Acceptance criteria
1. At T+0 the island holds troops only at the prison and its two nearby patrols;
measured active agents at mission start are under 20.
2. Within 10 minutes of the alarm, the radio station and the helipad have
garrisons that **arrived**, and the journal shows how each got there.
3. A player positioned along a lift route sees a real helicopter fly, land,
unload soldiers and depart.
4. Destroying that helicopter before it unloads permanently removes those
soldiers and that aircraft from the pool; the objective is logged as
unassigned and is still empty ten minutes later.
5. A player watching a junction sees a roadblock built in stages over minutes,
not appear at once. Leaving and returning shows progress continued.
6. Active agents never exceed `activeAgentBudget` at any sample during a
20-minute occupation, and never exceed 20 in the first minute after the alarm
despite the full deployment queue being issued.
7. A lift in transit costs 2 agents, not 10 — verified in the metrics output.
7. With the AI addon absent, occupation still completes by road and foot.
8. Restarting the mission leaves no deployment, aircraft, convoy or prop behind.
9. `python3 tools/check.py` passes.
## 11. Tests
`tests/test_occupation_plan.py`
- Priority order is respected while the pool lasts, and objectives are refused
once the pool is empty rather than spawning free sections.
- Pool accounting: a lost deployment permanently decrements sections and
transport; totals never go negative and never regenerate.
- Cadence and concurrency caps hold under a burst of eligible objectives.
`tests/test_deployment_rules.py`
- `Select` picks foot under 1.5 km, convoy in the middle band, air over 6 km, and
falls back correctly when transport is exhausted or no provider is registered.
- ETA scales with method speed and distance.
- A destroyed transport moves the order to `LOST` and releases the objective.
- A lift in transit reports zero passenger agents; the section's agents appear
only on unload, and the count matches the section strength.
- The physical-lift cap holds: with 6 queued lifts and a cap of 2, exactly 2 are
flown physically and the other 4 remain records with valid ETAs.
`tests/test_roadblock_build.py`
- Progress crosses the three prop stages exactly once each, in order.
- Progress advances with no observers present.
- Two resets lead to abandonment, not an infinite rebuild loop.
- The drivable gap is preserved in every completed variant.
---
## 12. Do not
- Do not spawn the island's garrisons at mission start "for now" — the staged
arrival is the feature, not an optimisation to add later.
- Do not write a second flight controller; use `ERR_AircraftService` through the
provider interface.
- Do not launch a fleet of helicopters at once. Ten physical lifts is ~90 agents
before a single garrison exists, front-loads the whole occupation into five
minutes, and stages nine flights where no player can see or stop them.
- Do not spawn passengers for a flight. Cargo is virtual until unload.
- Do not let core reference `ai` classes.
- Do not make deployments invulnerable or teleport them on failure. If the player
breaks a deployment, it stays broken.
- Do not exceed the caps to make a demo look better.
+164
View File
@@ -0,0 +1,164 @@
# Living island — plan and agent rules
Goal: Everon should feel occupied — patrols that actually move, manned roadblocks,
soldiers doing something other than standing — **without** raising the active AI
count. The target is a constant, bounded agent budget (default 60 enemy agents)
regardless of how much "activity" exists on the map.
## The core idea: simulate many, spawn few
Most of the island's forces exist as **records, not entities**. A record has a
position, a route, a strength and a duty. A slow tick advances records along
their routes with plain arithmetic — no AI, no navmesh, no entity. A record is
materialised into a real `SCR_AIGroup` only when a player is close enough to
observe it, or when HQ needs it in contact. When the player leaves, it goes back
to being a record, keeping its position and losses.
This is what buys the "living island" feeling cheaply: twelve patrols can be
crossing the map at once while only the two near players cost anything.
```
ERR_ForceLedger records: id, kind, position, route, strength, duty, importance
|
|-- ERR_ForceSimulation advances dormant records along routes (2 s tick, pure math)
|-- ERR_PresenceBudget decides which records are physical right now (3 s tick)
| |-- spawns -> SCR_AIGroup.SpawnMembers()
| |-- dormant -> SCR_AIGroup.DespawnMembers()
|
+-- consumed by ERR_HQ (ai addon) for tasking, ETA and availability
```
**Hard invariant — fairness:** only *physical* units observe and report contacts.
A dormant record never generates a sighting. This protects the HQ design, where
enemy knowledge must be earned through line of sight and radio.
## Layer 2: presence without population
A site feels occupied because of evidence, not head count. Evidence is free —
static props cost no CPU. So roadblocks, barriers, parked vehicles and lit
windows are **permanent**; the soldiers manning them are **conditional**.
## Layer 3: soldiers doing something
Vanilla smart actions (`SCR_AISmartActionComponent`) let an existing soldier lean
on a wall, smoke, or work on a vehicle instead of standing at a waypoint. This
costs no extra agents — it changes what the agents you already pay for are doing.
## Task order
Do these in order; each builds on the previous one. One agent per task.
| Task | File | Depends on |
| --- | --- | --- |
| T1 | `T1-force-ledger.md` — records + route simulation | — |
| T2 | `T2-presence-budget.md` — spawn/dormant arbitration under a cap | T1 |
| T3 | `T3-checkpoints.md` — roadblocks and manned checkpoints | T1, T2 |
| T4 | `T4-smart-actions.md` — idle activity at sites | T2 |
| T5 | `T5-hq-integration.md` — HQ tasks ledger units, gets ETAs | T1, T2 |
| T6 | `T6-ambient-signals.md` — cheap signs of life | T2 |
| T7 | `T7-performance.md` — caps, metrics, thrash prevention | all |
| T8 | `T8-hq-memory.md` — HQ belief: decaying contacts, searched ground | T5 |
| T9 | `T9-pursuit.md` — alert states, hunting, air search, cordons | T5, T8 |
| T10 | `T10-ai-migration.md` — port the old AI mod's proven behaviour under HQ | T5, T8, T9 |
| O1–O6 | `OCCUPATION.md` — how the island gets occupied after the alarm | T1, T2 |
T3 and T4 can run in parallel once T2 lands. T6 can run in parallel with T5.
T8 and T9 are what make the island *hunt* rather than merely be occupied; T9
depends on T8, because HQ cannot search for what it does not remember.
---
# Rules every agent must follow
Paste this section into each agent along with its task file.
## Licensing — non-negotiable
This project is an independent successor. The original Escapists mod and its
`ScrtCore` / `ScrtSharedContent` dependencies are under SCRT-ND (an APL-ND
variant) which **forbids derivatives**. Therefore:
- Never copy code, config, layouts or assets from the original Escapists mod,
from ScrtCore/ScrtSharedContent, or from `/home/jakub/devel/escapists`.
- Never introduce identifiers matching `ESCT*`, `SCRTC_*`, `SCRT_*`, `EAI_*`.
`tools/check.py` fails the build on these.
- Only vanilla Arma Reforger resources and resources owned by this repository
may be referenced. Every `{GUID}path` must either be in
`docs/vanilla-resources.json` or be owned by a `.meta` file in this repo.
- Reimplementing a *mechanic* from scratch is fine. Copying an implementation is not.
## Project constraints
- Five addons only: `core`, `ai`, `intel`, `chat`, `map`. Do not add addons or
external dependencies.
- Dependency direction: `ai`, `intel`, `map` depend on `core`; `chat` depends on
all. **`core` must never reference `ERR_HQ` or anything from another addon.**
Core defines interfaces (see `ERR_CommandService`); companions implement them.
- The living-island ledger belongs in **`core`**, because the base scenario must
work without the AI addon. HQ integration (T5) goes in **`ai`**.
## Performance rules — the whole point of this work
1. **No per-frame work.** Every tick in this subsystem is ≥ 1 s. Stagger work by
unit id (`id % N`) so all units never update on the same frame.
2. **No allocation inside loops.** Reuse member arrays; clear rather than
reallocate. Do not allocate an `array<AIAgent>` per unit per tick.
3. **Spatial bucketing for proximity.** 500 m cells, as already done in
`ERR_HQNetwork.Add/Build`. Never do an all-pairs distance scan.
4. **Hard caps everywhere,** with a logged refusal when hit. Never let a count
grow unbounded.
5. **Prefer vanilla behaviour over custom steering.** Issue a waypoint and let
the engine drive; do not write per-frame movement code.
6. **Hysteresis and dwell time.** Spawn and despawn radii must differ, and a unit
must stay in its state a minimum time before flipping. Thrash is the main
performance risk in this design.
## Code style
- Match the surrounding file's indentation (older files use tabs).
- New lines stay under 120 characters. The repo already has 420 lines over 200
characters; do not add more.
- Put decision logic in **pure static rule classes** (see `ERR_DiscoveryMapRules`,
`ERR_MissionRules`) so it can be tested without the engine. Entity manipulation
stays in the service class.
- Small classes. Do not extend `ERR_GameMode` or add another 100-field object.
## Validation — required before you report done
1. `python3 tools/check.py` must pass.
2. Add Python tests using `tests/policy_harness.py`: `body(path, signature)`
extracts a real function body from the `.c` file and `run(code)` compiles and
executes it as C++. Test the pure rule classes this way. Follow the pattern in
`tests/test_admin_session.py`.
3. Report honestly. This repo separates "compiles and passes scripted fixtures"
from "verified in the real game". If you did not run it in Workbench, say so.
Do not write evidence files claiming runtime verification you did not perform.
## Verified engine APIs you will need
All confirmed present in the installed vanilla scripts (1.6.30):
| API | Where | Notes |
| --- | --- | --- |
| `SCR_AIGroup.SetLifecyclePolicy(policy, spawnDist, despawnDist, veryNearBlockDist)` | `Entities/SCR_AIGroup.c:2921` | `Manual` (default) or `ProximityDriven` |
| `SCR_AIGroup.SpawnMembers()` / `DespawnMembers()` | `:2783` / `:2870` | Dormant group **keeps its entity** and records alive/dead counts, so a later `SpawnMembers()` re-materialises the right number |
| `ChimeraAIGroup.SetImportance(int)` / `AIGroup.GetImportance()` | `generated/AI/ChimeraAIGroup.c:22` | Pass `SCR_EAISpawnImportance` values |
| `SCR_EAISpawnImportance` | `AI/SCR_EAISpawnImportance.c` | LOW 0.50, NORMAL 0.70, HIGH 0.90, CRITICAL 1.00 — fraction of AIWorld's active-AI limit the tier may consume, enforced by `AIWorld::CanActivateGroup` |
| `SCR_EAIGroupLifecyclePolicy` | `AI/SCR_EAIGroupLifecyclePolicy.c` | `Manual` = we drive it; `ProximityDriven` = group self-manages against `ObserversSystem` |
| `ObserversSystem.GetObservers(out array<vector>)` | `generated/System/ObserversSystem.c:59` | Cheap observer positions; also `GetObserversMP` |
| `SCR_AIGroup.SetEliminateWhenReached(true)` + `GetOnEliminatedWhenReached()` | used at `SCR_AmbientPatrolSpawnPointComponent.c:212` | If a player reaches a patrol that could not spawn (budget full), the area counts as cleared and the group retires instead of popping in later |
| `SCR_AISmartActionComponent` (`ReserveAction`, `ReleaseAction`, `GetOnActionEnd`, `GetOnActionFailed`) | `AI/Components/SCR_AISmartActionComponent.c` | Idle activities |
| `SCR_SmartActionWaypoint.SetSmartActionEntity(IEntity, string tag)` | `AI/Behavior/SCR_SmartActionWaypoint.c:31` | Sends a group to perform a tagged smart action |
Reference implementation worth reading (vanilla, not the original mod — safe to
read and learn from): `Components/Locations/SCR_AmbientPatrolSpawnPointComponent.c`
and `Systems/SCR_AmbientPatrolSystem.c`.
## Existing repo helpers — use these, do not reinvent
- `ERR_Actors.Spawn/DeleteSpawned/Ground/Players/Alive/CanFight/Group` — `addons/core/Scripts/Game/ERR/ERR_Actors.c`
- `ERR_Resources.SearchWaypoint/MoveWaypoint/CycleWaypoint/DefendWaypoint` — waypoint prefab GUIDs
- `ERR_SiteService` / `ERR_SiteDefinition` / `ERR_LocationComponent` — authored sites and their guards
- `ERR_HQNetwork` — the 500 m spatial cell pattern to copy
- `ERR_Settings.Get("key")` — integer settings; add new keys here
- `ERR_HQJournal` via `ERR_HQ.Event(kind, message)` — **ai addon only**; core code logs with `PrintFormat("Reintroduced [ledger]: ...")`
+93
View File
@@ -0,0 +1,93 @@
# T1 — Force ledger and route simulation
Read `docs/living-island/README.md` first, including the rules section. Follow it.
## Goal
Introduce a server-owned record of every enemy formation on the island that does
not require those formations to exist as entities. Dormant records move along
routes using arithmetic on a slow tick. This is the foundation for everything
else in the living-island work.
## What to build
Create `addons/core/Scripts/Game/ERR/ERR_ForceLedger.c`:
```
enum ERR_ForceKind { GARRISON, PATROL, CHECKPOINT, CONVOY }
enum ERR_ForceDuty { HOLD, PATROL_ROUTE, MOVE_TO, SEARCH, RETURN }
class ERR_ForceRoute // ordered waypoints + loop flag
class ERR_ForceRecord // id, kind, position, heading, route, progress,
// strength, casualties, duty, dutyTarget,
// importance, radioCapability, group (null when dormant),
// lastSimulatedAt, stateSince
class ERR_ForceLedger // owns records; register/unregister/find/count/status
```
Create `addons/core/Scripts/Game/ERR/ERR_ForceSimulation.c` with a **pure rule
class** plus the tick that applies it:
```
class ERR_ForceRules
{
// pure, engine-free, unit-testable
static float Speed(ERR_ForceKind kind, ERR_ForceDuty duty); // m/s
static int Advance(...); // returns new route index
static vector Step(vector from, vector to, float speed, float seconds, out bool arrived);
static bool Due(int id, float now, float lastSimulatedAt, float interval); // staggered
}
```
Rules:
- Dormant records advance along their route polyline by `speed * elapsed`.
Walking speed default 1.4 m/s, vehicle convoy 11 m/s, a holding garrison 0.
- **Do not raycast, do not query the navmesh, do not touch the world** for
dormant records. Straight-line interpolation between authored route points is
the intended fidelity. Authored routes follow roads; that is the content's job,
not the simulation's.
- Y coordinate: keep the authored route point's Y. Only call `ERR_Actors.Ground()`
at the moment a record is materialised (that is T2's job, not yours).
- Tick interval default 2000 ms, staggered by `id % 8` so records do not all
update on the same frame.
- Cap the ledger at 128 records. Refuse beyond that and log once.
## Wiring
- `ERR_GameMode` owns the ledger, created next to the existing services
(`m_Sites`, `m_Discovery`, …). Expose `ERR_ForceLedger Forces()`.
- Tick it from the existing game-mode tick path. **Do not add a new timer or
`EOnFrame`.**
- `ERR_SiteService` registers a `GARRISON` record for each site guard group it
already creates, so the ledger has real content from day one. The site's
existing patrol cycle waypoints become the record's `ERR_ForceRoute`.
- Records whose group dies must be marked eliminated and stop simulating.
## Acceptance criteria
1. With five authored sites, the ledger holds five garrison records after
preparation, and `Status()` reports them.
2. A record with a 4-point looping route returns to its start after the expected
time, within 5% — verified by the Python test, not by eye.
3. Advancing 128 records for a simulated hour performs no world queries and no
per-record allocation.
4. `python3 tools/check.py` passes.
## Tests to write
`tests/test_force_ledger.py`, using `tests/policy_harness.py`:
- Extract `ERR_ForceRules.Step` and `Advance` with `body()` and run them as C++:
a 4-leg loop advanced in 2 s steps arrives at each leg in the expected order
and wraps correctly.
- `Speed` returns 0 for `HOLD`, and a convoy outruns a foot patrol.
- `Due` staggers: across ids 0..7 at the same `now`, not all are due.
- Off-by-one on the final leg of a non-looping route ends at the last point and
reports arrival exactly once.
## Do not
- Do not spawn or despawn anything in this task. T2 owns materialisation.
- Do not reference `ERR_HQ` — this is core.
- Do not add a `ref array` member that grows without a cap.
+274
View File
@@ -0,0 +1,274 @@
# T10 — Migrating the old AI mod's proven behaviour under HQ control
Read `docs/living-island/README.md` first, including the rules section.
Depends on T1, T2, T5, T8, T9. Mostly **ai** addon.
The predecessor project at `/home/jakub/devel/escapists/escapists-ai` already
implements much of what the living-island plan describes — hunting UAZ convoys, a
timed reconnaissance helicopter, reactive roadblocks, guard check-ins, body
discovery, mortar sites and supply convoys. That work is the maintainer's own
design and is the reference for this task.
This brief says what to carry across, what the real parameters were, and — most
importantly — how the architecture must change on the way.
---
## 1. Provenance and licensing — read before opening any source file
| Category | Files | What you may do |
| --- | --- | --- |
| Maintainer's own design | Most of `escapists-ai` | Carry the **design**: timings, distances, sequences, failure handling. Re-express the code against vanilla and ERR interfaces. |
| Calls into protected API | 36 of 48 files reference `ESCT_*` | Those calls cannot come across — ERR has no Escapists dependency. Replace each with a vanilla or ERR equivalent. A call is not a copy, but the replacement must be written fresh. |
| **Contains copied protected code** | `EAI_GroupLookup.c` | **Do not port, do not adapt, do not read as a model.** An independent comparison found 666 matching token sequences with the protected `ESCT_GroupLookup` / `ESCT_CharacterLookup`. Build group composition from vanilla entity catalogs from scratch, as `ERR_FactionCatalog` already does. |
Also binding:
- `tools/check.py:39` rejects any `EAI_*`, `ESCT*`, `SCRTC_*`, `SCRT_*` identifier.
Everything is renamed to `ERR_*`, and no old prefix survives in a comment either.
- Every `{GUID}path` must be in `docs/vanilla-resources.json` or owned here.
The prefabs quoted below are vanilla and were verified against the installed
`resourceDatabase.rdb`; add any that are missing from the manifest.
- Do not copy the old files wholesale and rename the symbols. Each system below
is re-implemented against the ledger, the belief layer and the presence budget.
---
## 2. The architectural change — this is the point of the task
In the old mod, each behaviour was an **independent subsystem with its own timer
and its own spawning**: `EAI_AirReconSystem` ran a launch clock, the response
system held a five-minute radio cooldown, the roadblock planner ran its own
placement loop, mortars managed their own crews. They competed for the same world
with no shared budget and no shared picture.
In this project every one of them becomes a **capability the HQ planner selects**,
under one force pool, one agent budget and one belief:
```
ERR_HQ ── belief (T8) ── what HQ thinks it knows
│
├── plans against objectives and beliefs (T5 scoring, T9 alert states)
│
└── chooses a capability and spends the pool:
ground response · air search · roadblock · mortar fire ·
supply convoy · garrison deployment (O1–O6)
```
Concretely, for every system below:
- **No private timers.** A capability offers "available / not available, ETA, cost";
the planner decides when. The old per-system cooldowns become availability
rules the planner reads.
- **No private spawning.** Units come from the ledger and the pool. A capability
requests a record; `ERR_PresenceBudget` decides what is physical.
- **No private world picture.** Capabilities act on beliefs (T8). Nothing queries
player positions directly.
- **Losses hit the shared pool.** A destroyed hunter UAZ is a section gone from
the same pool that garrisons checkpoints.
Delete rather than port: `EAI_Performance.NearPlayer` / `ConvoyLOD`. That was an
embryonic presence system; T2 replaces it, and two competing presence systems is
worse than either alone.
---
## M1 — Detection: check-ins, losses and bodies
**Source:** `EAI_HQWatch.c`, `EAI_HQBodySearch.c`, `EAI_HQStatus.c`
This is the missing trigger for the occupation alarm, and it already exists.
- **Periodic check-ins.** Each unit owes HQ a status report every `CHECK_IN_MS`
(180 s), with `REPLY_MS` (30 s) to answer. A unit that misses its check-in is
not assumed dead — HQ opens an *investigation* and sends someone to look. That
is the mechanism `OCCUPATION.md` referred to as "a guard check-in missed".
- **Loss confirmation.** `OnControllableDestroyed` records a loss as unconfirmed;
HQ only *knows* once a unit reports it. `SquadLimit(baseline, confirmedLosses)`
shrinks what HQ can commit as its confirmed losses mount.
- **Body discovery.** Bodies are found by units that can actually see them —
`InView(distance, facing)` plus a visibility trace, scanned at most every 15 s
per unit, capped at 16 tracked bodies. A found body queues radio evidence:
*casualty evidence, cause unknown*. Quiet kills stay quiet until someone walks
past the corpse, which is exactly the stealth counterplay worth keeping.
**Port as:** an ingest path into T8's belief and an alarm source for the
occupation plan. A discovered body creates a low-confidence belief at that
position; a missed check-in creates an investigation belief at the unit's last
known location. Keep the trace budget (`m_Traces`) — it is what stops body
scanning from costing a fortune.
---
## M2 — Ground response: the hunter convoy
**Source:** `EAI_AIResponseSystem.c`
The proven behaviour, with its real numbers:
- Two armed UAZs — `{0B4DEA8078B78A9B}Prefabs/Vehicles/Wheeled/UAZ469/UAZ469_PKM.et`
— spawned on roads `EAI_RESPONSE_DISTANCE` (1000 m, ±100 m tolerance) from the
contact, on **opposite sides**: candidate bearings must be at least 30° from
directly opposite (`dot ≤ -0.866`), up to 12 placement attempts.
- Each vehicle is filled with spec-ops and exactly one medic, all seats occupied.
- **One convoy is kept alive and re-tasked** when a later report gives a new
contact, rather than spawning a fresh one per sighting. It is replaced only
when a vehicle is lost.
- A global radio cooldown of 300 s gates how often a response can be triggered.
- Convoy gunners were given maximum AI skill. Keep a *deliberate* skill choice,
but make it a setting rather than a constant — see the pacing rules in T9.
**Port as:** the ground hunting capability in T9. The 1000 m opposite-approach
placement is exactly T9's "at most two bearings, escape vector left open" rule —
implement it once, here, and have T9 use it. Respect T9's
`minHunterSpawnMetres` (700 m): never place a response vehicle closer than that
to any player, which the old `EAI_ResponseSafety` and `EAI_SettlementSafety`
checks were already approximating.
---
## M3 — Air search: the reconnaissance helicopter
**Source:** `EAI_AirRecon.c` (1335 lines — the richest system in the old mod)
This *is* T9's hunter helicopter. Build one system, not two.
Behaviour worth carrying:
- **Launch timer.** `heliTimerMinutes` (default 15, range 1–1440) schedules the
first departure and each subsequent one. Losing the aircraft schedules a
replacement rather than ending air search permanently.
- **Airport discovery, spread over time.** The island is scanned in 2000 m
columns across successive updates — never in one frame — matching runway and
control-tower prefabs, clustering structures within 1000 m into one airport and
tracking the cluster centre. A named marker entity short-circuits the search.
**Keep the incremental scan pattern**; it is a good model for any map-wide
query in this project.
- **Landing pad selection.** 12 attempts at 35–220 m from the centre, rejecting
water, requiring flatness within 1.5 m across a 12 m cross, a 14 m radius
cylinder clear of low obstacles, and a clear box up to 150 m overhead.
- **Phased flight** with a landing retry: if a landing has not settled within
180 s, search for a new pad (re-checked every 30 s within 150 m of home).
- **Air observation feeds HQ** through the same report path as ground units.
**Change on the way across:**
- Fold into T9's air search: one airborne at a time, endurance-capped, observing
under the same line-of-sight rules as infantry.
- The launch timer becomes *availability*, not a trigger. The planner asks for
air search when a belief justifies it; the timer only says whether an airframe
is ready. A helicopter must not launch into an empty sky because a clock fired.
- Draw the airframe from the shared pool, so searching competes with lifting.
- Replace `ESCT_Misc.IsPositionInWater` and `ESCT_RoadNavigation` calls with
vanilla equivalents (`SCR_TerrainHelper`, `SCR_WorldTools`, vanilla road API).
---
## M4 — Reactive roadblocks
**Source:** `EAI_HQRoadblocks.c`
Distinct from T3's authored checkpoints: these are placed *in response to a
contact*, ahead of where HQ thinks the player is going.
- `Ahead(position, velocity, site)` rejects any site that is not in front of the
contact's travel. `FreshDirection(distress, hasVelocity, age, speed)` decides
whether a heading estimate is trustworthy enough to predict from at all.
- Sites are snapped to real roads via a road-approach search, within 200 m of the
chosen point, with a 25 m probe to orient the block along the road.
- Placement requires ground clearance, distance from players, and being outside
settlements.
**Port as:** the cordon mechanism in T9. T8's belief gives a better input than
the old raw velocity: use the uncertainty circle and the objective-route
inference to choose chokepoints between the belief and its likely destination.
Keep `Ahead` and `FreshDirection` as pure rules — they are good, testable logic.
---
## M5 — Mortar sites
**Source:** `EAI_MortarSystem.c`, `EAI_MortarEngagement.c`, `EAI_MortarFiring.c`,
`EAI_MortarPrediction.c`, `EAI_MortarSalvo.c`
Much of this is already migrated (`ERR_Mortar*`). What is missing is **site
placement as an HQ capability**: the old system chose where a battery stands,
spawned a 2B14 with crew and an RTO, equipped nine HE shells per crewman, pulled
shells from nearby donors when the gunner ran dry, reserved airspace over the
impact area, and audited site status (claimed / startup / operational / crew
fighting / shells / radio connected / cooldown).
**Port as:** a `MORTAR_SITE` objective in the occupation plan (O1). HQ deploys a
battery to a site like any other garrison — it costs a section from the pool,
travels by the normal methods, and takes time to become operational. A mortar
battery that is spent, overrun or out of shells stays spent. Keep the airspace
reservation and the existing fairness: a battery fires at *beliefs*, delivered by
radio, never at live player positions.
---
## M6 — Logistics and the threat extras
Lower priority; schedule after M1–M5.
| Source | What it gives | Port as |
| --- | --- | --- |
| `EAI_SupplyConvoys.c` (1463 lines) | Road convoy logistics with real routing | The convoy method in O4; reuse the road-following and slot logic, not the ESCT calls |
| `EAI_Resupply.c`, `EAI_AmmoSupply.c`, `EAI_DepotLoading.c`, `EAI_LogisticsVehicle.c` | Ammunition and depot chains | A resupply capability that keeps garrisons and batteries fighting — finite, so cutting a depot matters |
| `EAI_StingerEquipment.c` | A rare `Launcher_FIM92_Stinger` on enemy infantry, one in N | Anti-air threat against the player's stolen helicopter — the escape must not be trivially safe. Keep it rare and keep it a setting |
| `EAI_ImprovisedPrisons.c` | Recapture rather than only killing | A strong escape-fiction feature: an unconscious escapist can be taken, not just killed. Worth its own task later |
| `EAI_Mines.c`, `EAI_MineTask.c` | Mines laid on routes | Optional; only with clear warning cues, or it is pure frustration |
| `EAI_AirAssault.c`, `EAI_AssaultParachute.c` | Airborne insertion | Already covered by O3 air lift; do not build a second insertion path |
| `EAI_HelicopterRecovery.c`, `EAI_Landing.c`, `EAI_AircraftAmmo.c` | Aircraft servicing and recovery | Feed into the transport provider rather than a separate system |
---
## Acceptance criteria
1. No `EAI_*`, `ESCT_*` or `SCRTC_*` identifier appears anywhere in `addons/`;
`python3 tools/check.py` passes.
2. `EAI_GroupLookup.c` has not been ported in any form; group composition comes
from vanilla catalogs through `ERR_FactionCatalog`.
3. Every migrated capability draws units from the ledger and the shared pool.
Grep proves it: no capability calls a spawn helper directly.
4. No migrated system owns a private timer that triggers behaviour. Timers only
report availability to the planner.
5. A missed check-in produces an investigation; a discovered body produces a
low-confidence belief; both are visible in the journal.
6. A contact triggers **one** re-taskable hunter convoy, not one per sighting,
and never places a vehicle within 700 m of a player.
7. Air search launches because a belief justifies it, never because a clock
fired into an empty sky.
8. Destroying hunters, the helicopter or a mortar battery permanently reduces
what HQ can do next — verified in the metrics.
9. Active agents stay inside `activeAgentBudget` with every capability in play.
## Tests to write
`tests/test_detection.py` — check-in timing and reply windows; `SquadLimit`
shrinks with confirmed losses; `InView` rejects a body behind the observer;
the body-scan interval and tracking cap hold.
`tests/test_ground_response.py` — opposite-bearing placement satisfies the 30°
rule and the 1000 m ± 100 m band; the 700 m minimum to players holds; a second
contact re-tasks the existing convoy instead of spawning another; cooldown gates
repeats.
`tests/test_air_search.py` — the launch timer reports availability but never
triggers a launch on its own; pad selection rejects water, slopes over 1.5 m and
obstructed overhead; landing retry fires after 180 s.
`tests/test_roadblock_siting.py` — `Ahead` rejects sites behind the contact;
`FreshDirection` refuses a stale or speedless heading; chosen sites lie between
the belief and its inferred objective.
## Do not
- Do not port a file by renaming its symbols. Re-implement against the ledger,
the belief and the budget.
- Do not recreate the old parallel-subsystem architecture. One planner, one pool,
one budget, one picture.
- Do not port `EAI_GroupLookup.c`, and do not use it as a reference while writing
the replacement.
- Do not let any migrated capability read player positions directly. Everything
acts on beliefs earned through observation and radio.
+97
View File
@@ -0,0 +1,97 @@
# 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<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.
+73
View File
@@ -0,0 +1,73 @@
# T3 — Roadblocks and manned checkpoints
Read `docs/living-island/README.md` first, including the rules section. Depends on T1, T2.
## Goal
Make the road network feel controlled. A checkpoint is **permanent evidence**
(barriers, a parked truck, sandbags, a light at night) plus a **conditional
garrison** (2–4 soldiers, materialised only when someone is near). The evidence
costs no CPU and is always there; the soldiers cost agents and are not.
This is the single biggest "living island" win per CPU cycle: the player sees
occupied terrain everywhere and pays for soldiers only where they are.
## What to build
1. **Site kind.** Extend `ERR_LocationKind` with `CHECKPOINT`, and
`ERR_SiteDefinition` with the fields a checkpoint needs: prop layout choice,
garrison size, whether it blocks the road, and the approach direction.
Update `ERR_SiteDefinition` validation and its existing tests accordingly.
2. **Static layout.** `addons/core/Scripts/Game/ERR/ERR_CheckpointLayout.c`
places vanilla props relative to the site origin and a road heading: barriers
angled across one lane, sandbag positions, a parked vehicle, a barrel. Keep
it to **one authored arrangement with a mirrored variant** — do not build a
procedural composition system.
- Use only vanilla prefabs. Every `{GUID}path` you add must be present in
`docs/vanilla-resources.json`, or `tools/check.py` will reject it. Find real
GUIDs by searching the installed vanilla data, not by guessing.
- Props are spawned once during preparation and never ticked.
3. **Checkpoint records.** Each checkpoint registers a `CHECKPOINT` record with
`ERR_ForceDuty.HOLD`, importance `NORMAL`, and a small garrison. The record's
position is the checkpoint; it does not move.
4. **Authored content.** Add 6–10 checkpoints to
`addons/core/Configs/ERR/EveronSites.conf` on the main road junctions between
the prison, the radio station and the helicopter site — the routes players
actually travel. Pick junctions, bridges and town entrances.
5. **Behaviour when manned.** The garrison gets a `DefendWaypoint` at the
checkpoint. One soldier stands at the barrier, the rest hold nearby positions.
Idle activity comes from T4 — leave hooks, do not implement smart actions here.
## Acceptance criteria
1. Driving the prison → radio → helipad route, the player passes at least three
checkpoints, each visibly built and manned when approached.
2. With no players near any checkpoint, checkpoint garrisons contribute **zero**
active agents.
3. Props survive their garrison being wiped — a cleared checkpoint still looks
like a checkpoint, and the record is marked eliminated so it does not repopulate.
4. Checkpoint props never block the road so completely that a vanilla car cannot
pass; leave a drivable gap or a moveable barrier.
5. `python3 tools/check.py` passes, including the resource-GUID rules.
## Tests to write
`tests/test_checkpoint_layout.py`:
- The pure placement function returns positions that are within the expected
radius of the site origin and correctly rotated for a given road heading.
- The mirrored variant is the mirror image, and the drivable gap is preserved in
both variants (assert a minimum clear width along the road axis).
- `ERR_SiteDefinition` validation rejects a checkpoint with zero garrison or a
garrison above the allowed maximum.
## Do not
- Do not import any composition, prefab or layout from the original mod.
- Do not spawn checkpoint props per-approach; they are placed once at preparation.
- Do not exceed 12 checkpoints on the first pass. Authoring more is cheap in
props but adds up in records and in preparation time.
+77
View File
@@ -0,0 +1,77 @@
# T4 — Soldiers doing something
Read `docs/living-island/README.md` first, including the rules section. Depends on T2.
## Goal
The soldiers you already pay for should look busy rather than standing at a
waypoint. This adds **zero** agents — it changes behaviour, not population, and
is the cheapest immersion in the whole plan.
## What to build
`addons/core/Scripts/Game/ERR/ERR_SiteActivity.c`.
Use the vanilla smart-action system:
- `SCR_AISmartActionComponent` — sits on a world entity that offers an action;
`ReserveAction(AIAgent)`, `ReleaseAction()`, `GetOnActionEnd()`,
`GetOnActionFailed()`.
- `SCR_SmartActionWaypoint.SetSmartActionEntity(IEntity entityWithSmartAction,
string smartActionTag)` — the waypoint that sends a group member to perform it.
Per site, author a handful of **activity slots**: a position, a tag and which
site kinds use it. When a garrison is physical and not alerted, assign idle
members to free slots; when the site is alerted or the group is in combat,
release the slots immediately and let normal AI take over.
Sensible slots to start with, by site kind:
- **Checkpoint:** one soldier at the barrier facing the road, one leaning
against the parked vehicle, one seated.
- **Outpost / depot:** a sentry at the gate, someone working at a crate, someone
resting.
- **Radio station:** an operator near the equipment, a sentry on the door.
- **Prison:** a guard patrolling the walkway, a guard at the gate.
## Rules
- Activity assignment runs on the existing site tick, not a new one, and at most
once every 5 s per site.
- Never assign more than half a garrison to activities at once; the rest hold
defensive positions. A checkpoint where everyone is smoking is worse than one
where nobody is.
- Alert state wins instantly: on perception contact, release every slot for that
site in the same tick.
- Slots are reserved so two soldiers never claim the same one; release on
action end, action failure, death, and on going dormant.
- If the vanilla smart action fails or is unavailable for a slot, fall back to a
plain hold position and log once per site — never leave a soldier idle in a
broken state.
## Acceptance criteria
1. Approaching a manned checkpoint, at least one soldier is performing an
activity rather than standing.
2. Firing a shot near the site causes every activity to be released and the
garrison to respond, with no soldier stuck in an animation.
3. Active agent count is unchanged by this task — assert it in the admin status
output before and after.
4. A garrison that goes dormant and returns re-acquires activities without
leaking reservations (reservation count returns to zero while dormant).
5. `python3 tools/check.py` passes.
## Tests to write
`tests/test_site_activity.py`:
- The pure selection rule never assigns more than half the garrison.
- A slot already reserved is not handed to a second agent.
- Alert state returns an empty assignment set regardless of free slots.
- Release-on-dormant leaves zero reservations.
## Do not
- Do not write custom animation code. Use the vanilla smart-action system only.
- Do not add activity slots to the ledger records; slots are site content.
- Do not let an activity survive an alert, a death, or a dormant transition.
+96
View File
@@ -0,0 +1,96 @@
# T5 — HQ commands the ledger
Read `docs/living-island/README.md` first, including the rules section. Depends on T1, T2.
This task is in the **ai** addon. Core must not learn about HQ.
## Goal
Today `ERR_HQ.Assign()` can only task groups that physically exist near a
contact. After this task, HQ tasks **ledger records** — including dormant ones
far away — so a response can come from across the map, travel virtually, and
materialise when it arrives. This is what makes the island feel coordinated
rather than reactive, and it costs nothing while the responder is off-screen.
## What to build
1. **A core-side interface, implemented where the ledger lives.** Add to
`ERR_ForceLedger` (core):
```
int Available(vector near, float radius, out array<int> ids);
float EstimateSeconds(int recordId, vector destination); // distance / Speed()
bool Order(int recordId, ERR_ForceDuty duty, vector target);
bool Pin(int recordId, bool pinned);
ERR_ForceKind KindOf(int recordId);
int StrengthOf(int recordId);
```
These are generic force-management calls, not HQ concepts, so core owning
them does not violate the dependency direction.
2. **HQ uses them.** In `ERR_HQ.Assign()`, replace first-fit-within-radius with a
scored choice over ledger candidates:
```
score = ContactPriority(report) / max(EstimateSeconds(id, target), 1)
- CostOfLeaving(KindOf(id))
```
`CostOfLeaving` is high for a `CHECKPOINT` or a garrison at an objective and
low for a roving `PATROL`. Put this in a pure `ERR_HQTaskingRules` class so it
is testable.
3. **Ordering a dormant record.** A dormant record receives the order through the
ledger and changes duty to `MOVE_TO`/`SEARCH`; it travels virtually and
materialises through T2's normal proximity rules when it nears players.
HQ pins the record once it is within 1 km of the contact so it cannot go
dormant mid-engagement.
4. **Radio realism is preserved.** The existing `ERR_HQNetwork` rules apply
unchanged to **physical** units — a carrier that dies mid-transmission still
kills the message. For a dormant record, evaluate the same relay geometry
against ledger positions and the record's `radioCapability`, with the same
hop limits and latency, but without entities. Log both paths to the journal
so the two are distinguishable.
5. **Fairness invariant, unchanged:** dormant records never observe and never
generate contacts. They only receive orders and move. Enforce it in code, not
by convention — `Observe()` must refuse a record without a physical group.
## Acceptance criteria
1. A contact reported near the radio station can be answered by a patrol 3 km
away, which travels while dormant and appears as real AI on arrival.
2. Checkpoint garrisons are not stripped to chase distant contacts — verify that
`CostOfLeaving` keeps them in place unless nothing else is available.
3. HQ's journal shows the ETA it used and the record it chose, so the decision is
auditable after the fact.
4. Killing the radio operator of a physical reporting unit still severs the
report, exactly as today.
5. `python3 tools/check.py` passes.
## Also fix while you are here
`ERR_HQUnit.Eligible()` allocates a fresh `array<AIAgent>` on every call, and
`Assign()` calls it inside a contacts × units nested loop every 5 s. Cache the
agent array on the unit and refresh it on the network tick instead. Likewise,
replace the six linear scans over `m_Units` keyed by group (`ReserveGroup`,
`LogisticsToken`, `ReserveLogistics`, `MortarConnected`, `Downlink`,
`QueueSupplyStatus`) with a `map<SCR_AIGroup, ERR_HQUnit>` lookup.
## Tests to write
`tests/test_hq_tasking.py`:
- `EstimateSeconds` scales with distance and with kind speed.
- The scored choice prefers a closer patrol over a distant one at equal priority,
and prefers a distant patrol over a checkpoint garrison at equal distance.
- With only checkpoint garrisons available and a high-priority contact, one is
eventually released — the cost is a weight, not a veto.
- A dormant record refuses to produce an observation.
## Do not
- Do not let HQ spawn units directly. It asks the ledger; the budget decides.
- Do not add `ERR_HQ` references to core files.
- Do not remove the existing radio latency or failure handling to simplify this.
+87
View File
@@ -0,0 +1,87 @@
# T6 — Cheap signs of life
Read `docs/living-island/README.md` first, including the rules section. Depends on T2.
## Goal
Sell an occupied island through things that are not soldiers. Every item here is
either a static prop, a single entity, or a timed event — none of it adds a
standing AI presence.
Budget for this whole task: **at most 4 active agents at any time, island-wide.**
## What to build
`addons/core/Scripts/Game/ERR/ERR_AmbientLife.c`.
### 1. Static occupation markers (zero CPU)
Placed once during preparation, never ticked:
- Parked military vehicles at sites and checkpoints.
- Crates, barrels, tents, field tables at outposts.
- At night, lit windows and lamps at occupied sites; dark at abandoned ones.
Drive the light state from the site's eliminated flag, evaluated on the
existing environment tick, not a new one.
This alone changes how the island reads at 300 m, for nothing.
### 2. Road traffic that exists only when watched
A `CONVOY` ledger record: a vehicle plus 2 crew travelling a long road route.
It simulates virtually like anything else and materialises only near players.
Two convoy records island-wide is enough — the player meets traffic occasionally
and it is real when they do.
### 3. Distant transit
Occasionally (every 8–15 minutes, settings `ambientTransitSeconds`), a single
helicopter flies a high-altitude route across the map, well above small-arms
range, and despawns at the far edge. One vehicle, one pilot, bounded lifetime,
hard cap of one at a time. It must never route over the extraction site while
extraction is active.
### 4. Audio and radio texture
- Use only vanilla sound prefabs. **No audio from the original mod** — see the
licensing rules.
- Occasional distant, non-hostile gunfire near enemy sites (a training burst) is
a strong cue. Gate it hard: never within 400 m of a player, never during an
alert, at most once per 3 minutes.
## Rules
- Everything in this task is skippable: if the budget is full or the mission is
in a terminal phase, ambient life is the first thing dropped.
- One tick, ≥ 10 s, shared by all ambient features. No feature gets its own timer.
- Every ambient entity is owned and tracked so `Stop()` removes all of it. Nothing
here may survive a restart — the repo already had restart-cleanup work; do not
regress it.
- Ambient units are `SCR_EAISpawnImportance.LOW` so they lose the budget contest
first.
## Acceptance criteria
1. Standing still in open terrain for 10 minutes, a player sees or hears at least
two distinct signs of occupation.
2. Ambient features contribute at most 4 active agents at any moment, verified in
the admin status output.
3. Restarting the mission leaves no ambient entity behind.
4. No ambient event fires during extraction over the extraction site.
5. `python3 tools/check.py` passes.
## Tests to write
`tests/test_ambient_life.py`:
- The gating rule refuses a transit while one is already active, during a
terminal phase, and when the agent budget is full.
- The gunfire rule refuses within 400 m of a player and inside the cooldown.
- The ownership list is emptied by `Stop()`.
## Do not
- Do not add civilians in this task. A civilian population is a separate, much
larger piece of work with its own budget.
- Do not spawn ambient AI that patrols indefinitely; ambient means bounded.
- Do not reuse any audio, particle or composition asset from the original mod.
+68
View File
@@ -0,0 +1,68 @@
# T7 — Budget enforcement, metrics and anti-thrash
Read `docs/living-island/README.md` first, including the rules section. Depends on T1–T6.
## Goal
Prove the living island stays inside its CPU budget, and make it obvious when it
does not. Without this task the rest is a plausible design with no evidence.
## What to build
1. **A metrics surface.** `addons/core/Scripts/Game/ERR/ERR_ForceMetrics.c`:
- active agents, physical records, dormant records, refused spawns
- state flips in the last minute (the thrash indicator)
- simulation tick duration, presence tick duration (min / mean / max)
- budget headroom, and how long the budget has been saturated
Expose through the existing admin menu and `ERR_HQ.Event` journal (ai side).
Reuse `ERR_AdminProvider`; do not build a new UI.
2. **Thrash guard.** If any record flips state more than twice per minute, widen
that record's hysteresis bands automatically and log it. Repeated flipping is
the failure mode that turns this design from a saving into a cost.
3. **Saturation policy.** When the budget stays full for more than 60 s, shed
load in a defined order and log each step:
`ambient (T6)` → `distant patrols` → `checkpoint garrisons beyond 1.5 km` →
never shed: objective garrisons, HQ-pinned records, anything in combat.
4. **Tick cost discipline.** Measure the simulation and presence ticks. If either
exceeds its allowance (2 ms simulation, 3 ms presence) for 5 consecutive
ticks, halve the records processed per tick by increasing the stagger, and log.
Degrade smoothly rather than stuttering.
5. **A measured comparison.** Extend the existing performance-comparison tooling
(`docs/performance-comparison.md`) with a living-island run: same route, same
duration, ledger enabled versus disabled. Record active agents and frame time.
This is the number that decides whether the design worked.
## Acceptance criteria
1. A 20-minute session with a player driving the full route keeps active agents
at or under `activeAgentBudget` at every sample.
2. Total state flips across all records stay under 2 per minute on average.
3. Simulation and presence ticks stay inside their allowances at 128 records.
4. The shed order is observable in the journal when the budget saturates.
5. The comparison run is recorded with real numbers, and the write-up states
plainly whether frame time improved, stayed flat, or regressed.
6. `python3 tools/check.py` passes.
## Tests to write
`tests/test_force_metrics.py`:
- The thrash guard widens bands after the third flip inside a minute.
- The shed order never selects a pinned, in-combat or objective record, even when
everything else has already been shed.
- The tick-cost governor increases stagger after 5 consecutive overruns and
restores it after sustained headroom.
## Reporting rules
Follow the repo's evidence discipline. State which numbers came from a real
Workbench or server run and which came from scripted fixtures. If the comparison
was not run in the real game, say so in the write-up rather than implying it was.
A regression that is reported honestly is more useful than an improvement that is
not reproducible.
+117
View File
@@ -0,0 +1,117 @@
# T8 — HQ memory: belief, not a 2-minute cache
Read `docs/living-island/README.md` first, including the rules section.
This task is in the **ai** addon. Depends on T5.
## The problem
`ERR_HQ.Tick()` deletes every contact older than `ERR_MissionRules.ContactFresh`
(120 s), and `m_Contacts` is a flat array capped at 64. After two quiet minutes
HQ knows *nothing*. There is no record that a break happened, no last known
heading, no memory of which ground has already been searched. Velocity
extrapolation between two reports under 30 s apart is the only inference in the
system.
A commander's main asset is an accumulated picture. HQ currently has a sliding
window. Everything that makes the enemy feel like a thinking opponent — sweeping
outward from a last sighting, cutting off a route, giving up on cleared ground —
is impossible without this task.
## What to build
`addons/ai/Scripts/Game/ERR/ERR_HQBelief.c`, with the usual split: a pure rule
class and the service that applies it.
```
class ERR_HQBeliefRules
{
static float Confidence(float ageSeconds, float halfLife); // decays, never deletes
static float Radius(float ageSeconds, float growthPerSecond, float floor);
static bool Supersedes(float newConfidence, float oldConfidence, float distance);
static bool Actionable(float confidence, float radius); // worth tasking against
static int SweepCell(...); // next unsearched cell, nearest-first outward
}
class ERR_HQBelief { ... }
```
### Contacts decay, they do not vanish
Replace deletion with decay. A belief holds position, observation time,
confidence, an estimated heading, and the report that produced it.
- Confidence falls on a half-life (`beliefHalfLifeSeconds`, default 600).
- **Uncertainty grows into a radius.** A contact 4 minutes old is not a point —
it is a circle the target could have reached, expanding at
`beliefRadiusGrowth` (default 1.2 m/s, roughly walking pace, faster if the last
sighting was in a vehicle). This single value is what lets HQ search *an area*
instead of chasing a stale pixel.
- Beliefs become useless by growing wide and faint, not by being deleted. Retire
one only when its radius exceeds a cap (default 2000 m) or a newer belief
supersedes it.
- Cap at 64 beliefs; merge rather than duplicate when a new report lands inside
an existing belief's radius.
### Searched ground
A coarse coverage grid, 500 m cells, each holding "last swept at" and "swept by".
A unit that completes a search waypoint marks its cells. HQ uses this to sweep
*outward* from a last sighting instead of re-walking ground it already cleared.
Cells decay back to unswept over `coverageDecaySeconds` (default 900) — ground
cleared twenty minutes ago is not cleared now.
### Objective threat
Per objective, a standing threat score from nearby beliefs plus **route
inference**: escapists need a radio and then a helicopter, so the radio station
and the helipad carry standing weight even with no contacts at all. This is the
plot of the mission, and HQ is allowed to know its own doctrine — it is not
allowed to know where the players are.
### The fairness rule, enforced in code
Belief is fed **only** by reports delivered through the radio network. Never by
player positions, never by a dormant record, never by a direct world query.
Write the ingest path so there is exactly one entry point and it takes a
delivered `ERR_HQReport`. If a report never arrives because its carrier was
killed, HQ never learns it — that is the whole design.
## Wiring
- `ERR_HQ` owns the belief service and feeds it from `Accept()`.
- `Assign()` (T5) tasks against beliefs rather than raw contacts.
- The journal writes belief changes at a coarse level: new belief, superseded,
retired, area swept. Not every decay tick.
## Acceptance criteria
1. Ten minutes after the last sighting, HQ still knows roughly where to look and
its search area has grown accordingly — it does not behave as though nothing
ever happened.
2. A unit that sweeps an area marks it; the next search order goes to adjacent
unswept ground, not the same cells.
3. With no contacts at all, the radio station and helipad still carry threat and
still attract garrisons.
4. Killing the reporting unit's radio operator prevents the belief from ever
forming — verified, not assumed.
5. Belief count stays capped and merges correctly under a burst of reports.
6. `python3 tools/check.py` passes.
## Tests to write
`tests/test_hq_belief.py`:
- `Confidence` halves over the configured half-life and never returns negative.
- `Radius` at t=0 equals the floor and grows linearly; a vehicle sighting grows faster.
- `Supersedes` prefers a fresh nearby report over a stale wide one, and refuses
to replace a high-confidence belief with a distant low-confidence one.
- `SweepCell` returns nearest-first outward and never returns a cell swept
within the decay window.
- `Actionable` refuses a belief whose radius has outgrown the cap.
## Do not
- Do not delete beliefs on a timer. Decay is the mechanism.
- Do not let anything except a delivered report write into belief.
- Do not run belief decay per frame; recompute confidence and radius on read
from the stored timestamp. There is no decay tick at all.
+127
View File
@@ -0,0 +1,127 @@
# T9 — Pursuit: alert states, hunting, air search and cordons
Read `docs/living-island/README.md` first, including the rules section.
Depends on T5 and T8. Belief (T8) must exist — you cannot hunt what you do not
remember.
## The problem
An occupied island is not a hunting island. Today HQ answers a sighting with one
`AIWaypoint_SearchAndDestroy` at a predicted position, and releases the unit the
moment it perceives anything. Nothing tracks a fleeing player, nothing searches
from the air, nothing cuts a route. This task is where the pressure comes from.
## 1. Alert state
Per sector (reuse the 500 m coverage grid, grouped into ~2 km sectors):
| State | Trigger | Behaviour |
| --- | --- | --- |
| `CALM` | default | Garrisons hold, patrols run routes |
| `ALERTED` | a belief forms in the sector | Patrols tighten toward it, checkpoints check harder |
| `HUNTING` | actionable belief, confidence above threshold | Hunters dispatched, air search eligible |
| `CORDON` | high confidence and a known direction of travel | Chokepoints blocked between the contact and its likely objective |
States decay downward on `alertDecaySeconds` (default 420) without new reports.
Escalation is the pacing dial for the whole mission — it is what makes the island
feel like it is closing in, and what lets it relax when the player breaks contact.
## 2. Hunting on the ground
Dispatch at most `huntMaxUnits` (default 3) against one belief. Hunters sweep the
**uncertainty circle**, not a point: take the nearest unswept cells from T8's
coverage grid and issue search waypoints outward from the last sighting.
- A hunter that completes its sweep reports back — "area searched, nothing found"
— which marks the cells and lowers that belief's confidence. **HQ must be able
to learn that a search failed.** Today it cannot, and that is a core defect.
- Do not release the assignment the instant a unit perceives something. Combat
suspends the sweep; when combat ends the sweep resumes or is re-tasked.
- A belief that has been swept out returns the sector to `ALERTED`, then `CALM`.
## 3. Air search — the hunter helicopter
This is a **separate role from transport**. One at a time
(`airSearchConcurrent`, default 1), drawn from the same aircraft pool, so a
helicopter searching is a helicopter not lifting troops.
- Flies a search pattern (expanding box or lane search) over the belief's
uncertainty circle at low altitude, using the existing `ERR_AircraftService`
through the same provider interface as air lift.
- It observes under the **normal fairness rules** — `ERR_HQObservation` line of
sight, exposure and distance gating apply to its crew exactly as to a foot
patrol. A helicopter sees more because it is high and moving, not because it
cheats.
- It reports by radio like any other unit. Shooting it down denies HQ its best
sensor, and the loss is permanent from the pool.
- Fuel/endurance cap (`airSearchSeconds`, default 420) then it returns to
staging. An unlimited eye in the sky is oppressive and expensive.
## 4. Cordons
When a sector reaches `CORDON`, place up to `cordonChokepoints` (default 4)
blocking positions **between the belief and the objective it is probably heading
for** — bridges, road junctions, ridge saddles. Not on top of the player.
Cordon positions are authored chokepoints on the site map, selected by which ones
lie on plausible routes from the belief to the radio station or helipad. This is
the payoff of the staleness problem: HQ knows its picture is old, so it blocks
where the player must pass rather than chasing where the player was.
## 5. Pacing — this decides whether the mission is fun or miserable
Players start with nothing. Unbounded pressure is not difficulty, it is a
cutscene about dying. Enforce all of these:
- **Never materialise a hunter within `minHunterSpawnMetres`** (default 700) of
any player. No unit ever appears close enough to feel teleported in.
- **No convergence from all sides.** Hunters approach from at most two bearings;
leave an escape vector open. A closing ring with no gap is a death sentence.
- **Cooldown after contact is lost** (`huntCooldownSeconds`, default 180) before
a fresh hunt on the same belief. Breaking contact must be rewarded.
- **Hunting spends the pool.** Sections hunting are not garrisoning; if HQ
commits hard to a hunt, its checkpoints thin and routes open elsewhere. Do not
paper over this — it is the strategic trade that makes the system a game rather
than a pressure dial.
- Night reduces air search effectiveness and sighting range; it should be the
player's window.
## Acceptance criteria
1. Being seen and then breaking contact produces a visible search: units sweeping
outward from where you were, a helicopter overhead, and then — if you stay
hidden and still — the search moving away and the sector calming down.
2. A failed sweep lowers confidence; HQ does not re-search the same cells.
3. The hunter helicopter spots players only under the same LOS/exposure rules as
infantry; destroying it measurably degrades HQ's picture.
4. A cordon appears between the player and the objective, not around the player's
exact position.
5. No hunter ever materialises within 700 m of a player, and hunters never
approach from more than two bearings at once.
6. Heavy hunting visibly thins garrisons elsewhere — verify by checking a
checkpoint's strength during a major hunt.
7. Active agents stay inside `activeAgentBudget` throughout.
8. `python3 tools/check.py` passes.
## Tests to write
`tests/test_pursuit.py`:
- Alert escalation and decay: state rises with confidence and falls after the
decay window with no new reports.
- Hunter dispatch respects `huntMaxUnits`, the cooldown, and the minimum spawn
distance.
- Bearing rule: given four candidate hunters around a belief, at most two
distinct approach bearings are selected and an escape vector remains.
- Cordon selection picks chokepoints on the belief→objective path, never the
belief's own cell.
- Air search respects concurrency and endurance, and returns to staging on
timeout.
## Do not
- Do not give hunters or the search helicopter any knowledge the fairness rules
would not produce. The pressure must come from good search, not from cheating.
- Do not stack hunts on multiple beliefs simultaneously beyond the unit cap.
- Do not let the cordon seal every route. There is always a way through; it
should be slow, exposed or unpleasant, never absent.