Zone management¶
Source: src/CTLD_zone.lua
Entities: CTLDTroopZone, CTLDLogisticZone
Singleton: CTLDZoneManager
Zones are the spatial anchors of CTLD: where troops board and deploy, where mission objectives are counted, where crates and vehicles can be serviced, and where AI transports pick up and drop off cargo. A single manager owns every zone; entities carry the per-zone state and geometry tests.
CTLDZoneManager follows the singleton idiom described in
Architecture — CTLDZoneManager = class(), a private _instance, and a
getInstance() factory that runs init() on first access. The two entity classes are plain
class() types instantiated with :new(data).
Zone types¶
| Type | Prefix / source | Entity | Purpose |
|---|---|---|---|
| TRZ | TRZ_ DCS trigger zone |
CTLDTroopZone |
Troop pickup, extract objective, mixed, or inert marker |
| WPZ | WPZ_ DCS trigger zone |
CTLDTroopZone (isWaypoint) |
March destination for deployed troops |
| LGZ | LGZ_ DCS trigger zone |
CTLDLogisticZone |
Crate / vehicle logistic services |
| AIZ | aiZones config table |
CTLDTroopZone (isAIPickup / isAIDropoff) |
AI-transport-only pickup / dropoff (Feature S/T) |
| Legacy | troopZones / wpZones / logisticUnits config |
CTLDTroopZone / CTLDLogisticZone |
Backward-compat for the old PKZ/IAZ/WPZ/EXZ conventions |
| EXZ (dynamic) | createExtractZone() at runtime |
CTLDTroopZone |
Extract zone created from a mission DO SCRIPT |
TRZ, WPZ and LGZ are discovered by scanning env.mission.triggers.zones at init. AIZ zones
are loaded from ctld.gs("aiZones") and reference an existing Mission Editor trigger zone by
name (dcsZoneName), resolved via trigger.misc.getZone. Legacy zones come from the
ctld.gs(...) config tables and are loaded last so that a modern TRZ_/LGZ_ definition
always wins: existing entries are never overwritten.
TRZ naming convention¶
A troop zone encodes all of its behaviour in the trigger-zone name — a strict positional scheme with all five fields required:
TRZ_<name>_<A|R|B|N>_<stock>_<flag>_<target>
| Position | Field | Type | Values | Meaning |
|---|---|---|---|---|
| 1 | TRZ_ |
prefix | literal | Zone type identifier |
| 2 | name |
string | any, no underscores, not a reserved word | Zone identifier (zoneName) |
| 3 | coalition |
enum | A R B N |
A=all, R=RED, B=BLUE, N=NEUTRAL |
| 4 | stock |
integer 0–999 | 0=no pickup, 1–998=limited, 999=unlimited |
Troop boarding capacity |
| 5 | flag |
string | DCS flag name, or nil |
Objective flag incremented on extract; nil=no objective |
| 6 | target |
integer ≥0 | 0=no win condition, N≥1=soldier threshold |
Soldiers needed to complete the objective |
Reserved words that may not be used as name: nil, A, R, B, N.
Field semantics¶
The parser maps the on-screen field values to internal storage that intentionally differs, so
that nil can mean "capability absent":
stock value |
Internal pickMaxStock |
Meaning |
|---|---|---|
0 |
nil |
Zone cannot board troops |
1–998 |
1–998 |
Limited stock, decrements on load |
999 |
0 |
Unlimited pickup |
flag value |
Internal objectiveFlag |
Meaning |
|---|---|---|
nil |
nil |
No objective flag |
| any string | the string | DCS flag name, incremented by soldier count on extract |
target value |
Internal objectiveTarget |
Meaning |
|---|---|---|
0 |
nil |
No win threshold defined |
N≥1 |
N |
Soldier count required for objective completion |
Use
999for unlimited pickup — not0.0means "this zone cannot board troops".
For coalition, A→0, R→coalition.side.RED (1), B→coalition.side.BLUE (2),
N→coalition.side.NEUTRAL (0). A and N both store 0; internally CTLD treats 0 as
"accept all coalitions", and DCS multiplayer has no NEUTRAL players, so the two behave
identically.
Zone roles¶
A TRZ can act as any combination of:
- Pickup (
stock > 0): troops board here; boarding decrements stock. - Extract (
flag ≠ nil): troops deployed here increment the objective flag by the soldier count. - Mixed (both): supports boarding and objective extract.
- Inert (
stock=0,flag=nil): parsed but functionless — a named marker.
Examples¶
| Zone name | Role | Boarding | Objective |
|---|---|---|---|
TRZ_base_B_50_nil_0 |
Pickup | 50 soldiers | — |
TRZ_depot_A_999_nil_0 |
Pickup ∞ | unlimited | — |
TRZ_exfil_R_0_rescue_0 |
Extract | — | flag rescue, no threshold |
TRZ_lz_B_0_secure_100 |
Extract | — | flag secure, 100 soldiers |
TRZ_fob_N_30_defend_50 |
Mixed | 30 soldiers | flag defend, 50 soldiers |
TRZ_marker_B_0_nil_0 |
Inert | — | — |
TRZ _ fob _ N _ 30 _ defend _ 50
│ │ │ │ │ │
│ │ │ │ │ └─ target: 50 soldiers for the objective
│ │ │ │ └──────────── flag: "defend" (DCS flag name)
│ │ │ └─────────────────────── stock: 30 troops max (limited)
│ │ └────────────────────────────── coalition: NEUTRAL
│ └───────────────────────────────────── name: "fob"
└───────────────────────────────────────────── prefix TRZ
Parser validation¶
CTLDZoneManager:_parseTRZ(name) returns a parsed table on success, or nil plus an error
string on failure:
| Violation | Error message |
|---|---|
| Prefix ≠ TRZ | "not a TRZ" |
| Missing or empty name | "missing zoneName" |
| Name is a reserved word | "zoneName cannot be a reserved word: <word>" |
| Missing coalition | "missing coalition (A|R|B|N)" |
| Coalition not A/R/B/N | "invalid coalition '<x>' — expected A, R, B or N" |
| Stock missing / non-integer / outside 0–999 | "invalid stock '<x>' — expected integer 0-999 …" |
| Missing flag | "missing flag (DCS flag name or 'nil')" |
| Flag is a number | "flag must be a string or 'nil', not a number" |
| Target missing / negative / non-integer | "invalid target '<x>' — expected integer ≥0 …" |
WPZ_ and LGZ_ use the simpler <PREFIX>_<name>_[R|B|N] form (coalition optional, default
0); _parseWPZ shares _parseSimpleZone and _parseLGZ is standalone.
Discovery algorithm¶
CTLDZoneManager:init() registers S_EVENT_DEAD on the DCS event bridge, then runs the
following phases in this order:
_validateZoneNames()— scans every trigger zone and theaiZonesconfig, accumulates errors and warnings (see below), and reports them once to the DCS log and on-screen._discoverTRZ()— for each trigger zone starting withTRZ_, parse the name with_parseTRZ; on success construct aCTLDTroopZone(geometry from the trigger zone,smokefromtroopZoneSmokeColor[coalition]) and reset anyobjectiveFlagto 0._loadAIZonesFromConfig()— readsctld.gs("aiZones"); each valid entry becomes aCTLDTroopZonemarkedisAIPickup/isAIDropoff, with per-template/per-type stock._discoverWPZ()— trigger zones starting withWPZ_become waypoint troop zones (isWaypoint = true)._discoverLGZ()— trigger zones starting withLGZ_becomeCTLDLogisticZoneobjects (radius fromdynamicZoneRadius, default 200 m)._loadLegacyZones()— backward-compat pass over thetroopZones,wpZonesandlogisticUnitsconfig tables._scheduleSmoke()— starts the recurring smoke refresh loop.
Finally init() publishes an initial OnLogisticZoneUpdated and logs the zone counts. Each
discovery phase guards on if not self._troopZones[name] / _logisticZones[name], so the
precedence order above is what makes modern definitions win over legacy ones.
Legacy fallback details¶
troopZonesentries support both DCS trigger zones and ship unit names — iftrigger.misc.getZonemisses, the loader falls back toUnit.getByName, snapshotting the ship's position at init as a mobile pickup point. Legacy troop zones auto-derive astockFlagNameof<zoneName>_count, mirroringpickCurrentStockto that DCS flag.logisticUnitsentries resolve viaStaticObject.getByNameorUnit.getByNameand become dynamic logistic zones linked to that object (radius frommaximumDistanceLogistic, default 500 m).
CTLDTroopZone¶
Constructed from a data table. Required: dcsName, zoneName, coalition, center
(vec3), radius. Optional: verticies, pickMaxStock, objectiveFlag, objectiveTarget,
stockFlagName, smoke (a trigger.smokeColor.* value or -1), active, the role flags
isWaypoint / isDropoff / isAIPickup / isAIDropoff, and the AI fields aiDropMode,
aiCargoType, troopTemplates, vehicleTypes, _aiTroopStock, _aiVehicleStock.
Role predicates¶
| Method | True when |
|---|---|
zone:hasPickup() |
pickMaxStock ~= nil |
zone:hasExtract() |
objectiveFlag ~= nil |
zone:hasWaypoint() |
isWaypoint == true |
zone:hasDropoff() |
isDropoff == true (IAZ legacy auto-drop) |
zone:hasAIPickup() |
isAIPickup == true (AIZ P role) |
zone:hasAIDropoff() |
isAIDropoff == true (AIZ D role) |
Geometry¶
zone:isInZone(point) tests polygonal zones (verticies with ≥3 corners) via a Jordan
ray-cast on the private static CTLDTroopZone._raycast, otherwise falls back to a circular
radius test. Note that verticies[i].x/.y are mission-file coordinates where mission Y
equals world Z. zone:getCenter() returns the stored center.
Stock management¶
zone:consumeStock(n) -- decrement pickCurrentStock by n; true on success, unlimited always true
zone:restoreStock(n) -- restore up to pickMaxStock; no-op for unlimited or non-pickup zones
Both call _syncStockFlag(), which writes pickCurrentStock to stockFlagName via
trigger.action.setUserFlag when that flag is set. Unlimited stock (pickMaxStock == 0) means
consumeStock always succeeds and restoreStock is a no-op.
AI per-template / per-type stock (Feature T)¶
AIZ zones can carry granular stock instead of a single count. _aiTroopStock and
_aiVehicleStock are tables of the shape { isAll = bool, init = {name=N}, current = {name=N} },
where N = -1 means unlimited. The special config key "All" sets isAll = true (every
template/type, unlimited).
zone:aiPickTroopTemplate(teams, typeName, unitName, tm) -- best eligible template, or nil
zone:aiConsumeTroopStock(templateName) -- decrement one troop unit
zone:aiRestoreTroopStock(templateName, n) -- restore n (default 1), capped at init
zone:aiPickVehicleEntry() -- { type, isScene, isAASystem } or nil
zone:aiConsumeVehicleStock(typeName) -- decrement one vehicle
zone:aiRestoreVehicleStock(typeName) -- restore one, capped at init
aiPickTroopTemplate filters templates that are in stock and fit the transport's capacity
(tm:_canEmbark), then prefers the highest current stock, choosing randomly among ties.
aiPickVehicleEntry resolves each type's provider in priority order
CTLDSceneManager (scene) > CTLDCrateAssemblyManager (AA system) > DCS native, returning
nil when isAll is set (the caller then uses the legacy physical-scan path).
Objective¶
zone:incrementObjective(soldierCount) -- → incremented(bool), valueBefore, valueAfter
Adds soldierCount to objectiveFlag (mode: +N per soldier, not per group or per
operation) and logs an INFO line when objectiveTarget is reached. CTLD only ever
increments the flag; it resets objectiveFlag to 0 once at discovery, and the mission maker
defines the win condition in the DCS Editor with a trigger such as if flag >= target → victory.
zone:activate() / zone:deactivate() toggle the active flag.
CTLDLogisticZone¶
Constructed from data. Required: name, coalition, center, radius (default 200).
Optional: linkedUnit (the zone then follows this DCS unit/static), active, and a services
table which defaults to { cratesPickup = true, cratesDropoff = true, vehicleSpawn = true }.
| Method | Behaviour |
|---|---|
zone:getCenter() |
Linked unit's live point if it still exists, else the stored center |
zone:isDynamic() |
True if anchored to a moving DCS unit (_linkedUnit ~= nil) |
zone:isAlive() |
True unless the linked unit no longer exists (always true for static) |
zone:isInZone(point) |
Circular radius test only — logistic zones are always circular |
zone:activate() / zone:deactivate() |
Toggle active |
Smoke scheduler¶
_scheduleSmoke() re-arms itself every smokeRefreshInterval seconds (default 300). When
disableAllSmoke is set it re-schedules without doing anything. Otherwise it smokes every
active troop zone whose smoke >= 0 and every active logistic zone that has a colour in
logisticZoneSmokeColor[coalition], then publishes OnZoneSmokeRefreshed with a snapshot of
both zone sets (positions, stock, objective progress, colours).
Events¶
Published through EventDispatcher (see Architecture):
| Event | Published when |
|---|---|
OnZoneSmokeRefreshed |
Every smokeRefreshInterval seconds by the smoke loop |
OnLogisticZoneUpdated |
At init, on dynamic-unit death, and on FOB / logistic register / unregister / (de)activate |
The manager registers onDead for S_EVENT_DEAD via CTLDDCSEventBridge: when a dynamic
logistic zone's linked unit dies, the zone is removed and an OnLogisticZoneUpdated is
published with the removal.
Query API — troop zones¶
zm:getTroopZone(zoneName) -- → CTLDTroopZone | nil
zm:getTroopZonesForCoalition(coalition) -- active zones for coalition (0 = both)
zm:getTroopZoneAtPoint(point, coalition) -- containing zone; excludes AI-only pickup zones
zm:getTroopZoneForUnit(unitName) -- zone under a live unit's position
zm:getWaypointZoneAt(point, coalition) -- active WPZ containing point
zm:getNearestWaypointZone(point, coalition) -- nearest WPZ (point need not be inside)
zm:getDropoffZoneAt(point, coalition) -- active IAZ-legacy auto-drop zone at point
zm:getAIPickupZoneAt(point, coalition) -- active AIZ P zone at point (smallest radius wins)
zm:getAIDropoffZoneAt(point, coalition) -- active AIZ D zone at point (smallest radius wins)
Coalition filtering everywhere follows the same rule: a zone matches when
coalition == 0 (caller accepts all), or zone.coalition == 0 (zone serves all), or the two
sides are equal. getNearestWaypointZone backs Feature I (gotoNearestWPZ), and the AI
pickup/dropoff getters back the AI transport loop (_checkAIStatus).
Query API — logistic zones¶
zm:getLogisticZone(name) -- → CTLDLogisticZone | nil
zm:getLogisticZonesForCoalition(coalition) -- active + alive zones for coalition
zm:getLogisticZoneAtPoint(point, coalition) -- first containing zone
zm:getLogisticZoneForUnit(unitName) -- zone under a unit / static
zm:getLogisticZonesAtPoint(point, coalition, key) -- ALL containing zones; optional services filter
getLogisticZonesAtPoint returns every matching zone (not just the first). The optional
serviceKey (e.g. "cratesPickup") keeps only zones where zone.services[key] is not
false.
Runtime registration and legacy-compatible API¶
zm:registerFOBAsLogistic(fobName, point, radius, coalitionId) -- add a built FOB as an LGZ
zm:unregisterLogistic(name) -- remove a dynamic LGZ (FOB destroyed)
zm:deactivateLogisticZone(name) -- reversible disable (capture / loss)
zm:activateLogisticZone(name) -- re-enable
zm:setTroopZoneActive(zoneName, active) -- enable / disable a TRZ
-- Legacy-compatible (called by the legacy API wrappers):
zm:isUnitInZone(unitName, zoneType) -- zoneType: "extract" | "pickup" | nil (any active zone)
zm:createExtractZone(zoneName, flag, smoke) -- runtime EXZ from a DCS trigger zone
zm:removeExtractZone(zoneName, flag) -- flag accepted but ignored
zm:activateWaypointZone(zoneName) -- delegates to setTroopZoneActive(…, true)
zm:deactivateWaypointZone(zoneName) -- delegates to setTroopZoneActive(…, false)
zm:changeRemainingGroups(zoneName, amount) -- adjust pickCurrentStock by ±amount (floored at 0)
Deactivated logistic zones are ignored by every getter until reactivated, which lets a mission simulate a captured or temporarily lost resupply point without destroying it.
Unload priority: extract before RTB¶
When a player unloads troops, the containing zone is evaluated in this order (handled by the troop subsystem, not the zone manager itself):
- TRZ with
objectiveFlag→ extract (deploy troops, increment the flag). - TRZ with pickup only → RTB (troops return, stock restored).
- Otherwise → combat deploy (troops deployed, no flag, no stock change).
This ordering ensures a mixed zone triggers its objective when used as an extract point rather than merely returning the troops to stock.
F10 menu behaviour¶
The troop-command menu is rebuilt on S_EVENT_LAND / S_EVENT_TAKEOFF from the player's
current position and cargo state:
- In flight: the "Troop Commands" submenu is empty.
- On the ground inside a pickup TRZ: a "Load from
<zoneName>" option appears. - On the ground with troops onboard: an "Unload / Extract" option appears.
- On the ground outside any TRZ: no load options.
Zone-name validation¶
_validateZoneNames() runs first in init() and produces a single grouped report
(trigger.action.outText + DCS log + env.warning). It covers:
- TRZ / WPZ / LGZ: any trigger zone with the prefix that fails its parser is reported as an error.
- AIZ config (Features S/T): per-entry checks accumulate errors and warnings, keyed by
dcsZoneNameso that only invalid entries are skipped in_loadAIZonesFromConfig.
| Rule | Severity | Condition |
|---|---|---|
| — | error | missing dcsZoneName, duplicate dcsZoneName, zone absent from the Mission Editor, or missing/invalid coalition |
| G1 | error | neither isPickup nor isDropoff — the zone would do nothing |
| G5 | error | vehicle cargoType (V/TV) on a pickup zone but no aircraft has canTransportWholeVehicle |
| — | warn | invalid cargoType (defaults to T) or invalid aiDropMode (defaults to GP) |
| G3 | warn | pickup + troop cargo but troopStock undefined — troop pickup disabled |
| G6 | warn | pickup + vehicle cargo but vehicleStock undefined — vehicle pickup disabled |
| G2 | warn | every entry in troopTemplates is unknown (per-name warnings also emitted) |
| G4 | warn | every entry in vehicleTypes is unknown in the loadable-vehicle lists |
| — | warn | a pickup zone overlaps a dropoff zone of the same coalition — risk of an instant pickup/dropoff loop |
When there are no findings it logs CTLDZoneManager: zone config valid instead.