DOMINATION

Campaign Mission Authoring Guide

A working handbook for adding single-player campaign missions to DOMI-NATION. Everything here is derived from the actual runtime code; the authoritative sources are:

  • unity/Assets/Scripts/Game/CampaignData.cs - MissionDef schema, JSON converters, chain/unlock logic
  • unity/Assets/Scripts/Game/CampaignUI.Triggers.cs - the trigger runtime (events, actions, timers, waves)
  • unity/Assets/Scripts/Game/CampaignUI.Binding.cs - named-entity binding and ref resolution
  • unity/Assets/Scripts/Game/CampaignUI.Dialog.cs - dialog presentation, choices
  • unity/Assets/Scripts/Game/CampaignProgress.cs - persistent distrust + completion (PlayerPrefs)
  • unity/Assets/StreamingAssets/data/campaign/*.json - the missions
  • unity/Assets/StreamingAssets/data/maps/*.json - the authored maps (unity/Assets/Scripts/Map/MapDescriptor.cs)

1. Architecture overview

The campaign is fully data-driven. Drop a JSON file in unity/Assets/StreamingAssets/data/campaign/ and it appears on the campaign-select screen; no code changes are needed for a normal mission.

Data flow:

  1. MissionDef.LoadAll() scans data/campaign/*.json. Malformed files are skipped WITH a logged reason ([Campaign] Skipped '<file>': ...); a healthy load logs [Campaign] loaded N, skipped 0. Missions with act <= 0 default to act 1.
  2. Campaign.Build() groups missions into chains by faction (usa / russia / china; anything else, e.g. united_front, lands in the trailing finale group) and sorts each chain by act, then by ordinal mission id. Campaign.IsUnlocked() drives the select screen: the first mission of each chain is always unlocked; each later mission unlocks when the previous one is complete; the finale unlocks only when every act-4 mission across all factions is complete (Campaign.FinaleUnlocked; if no act-4 mission exists, ALL base-faction missions are required).
  3. Starting a mission hands the MissionDef to GameController. MissionDef.ApplyMap loads the authored map (data/maps/<mapId>.json) or falls back to the inline map block. SetupMission builds the player base at start 0 and spawns intro.playerUnits there; intro.enemyUnits spawn at the enemy start and immediately push the player HQ.
  4. CampaignUI shows the briefing, then BeginPlay starts the act music (Campaign.MusicKeyForAct, stems under Resources/audio/campaign/) and calls InitRunner, which arms scriptedTriggers, runs the content-binding pass, and fires missionStart triggers.
  5. TickRunner polls every active trigger each frame while Playing. A satisfied event + condition gate fires the trigger's actions in order (per-action delay supported). endMission / completeMission / failMission force the outcome; a win plays the victory[] cutscene and calls CampaignProgress.MarkComplete, which may queue an act-break montage (Campaign.ActBreakTransition, once-only via CampaignProgress.ActBreakSeen).
  6. If a mission has NO scriptedTriggers, the legacy win applies: clear the map of enemy units. If it HAS triggers, the legacy auto-win is disabled and you MUST script a win path.

Persistence (CampaignProgress, PlayerPrefs-backed): campaign_distrust (0..100, ending gate at DistrustThreshold = 60), campaign_completed (semicolon-joined mission ids), campaign_actbreak_seen_<act>. CampaignProgress.Reset() wipes all three.

Campaign saves

CampaignSaves layers named save slots on top of that progression state. Each slot is a JSON file at <persistentDataPath>/saves/campaign/<slot>.json (Newtonsoft, the project standard). Slots: slot1 / slot2 / slot3 (manual, via the campaign-select screen's SAVE buttons), autosave (written after every mission victory, from CampaignUI right after CampaignProgress.MarkComplete), and quicksave (written by the in-mission pause menu's SAVE CAMPAIGN PROGRESS button). The file stores schemaVersion, name, savedAtUtc, distrust, completed[], actBreaksSeen[], campaignFlags[], and a display-only summary (missions complete / highest act / furthest mission). Loading a slot restores the progression PlayerPrefs (clear-then-set) and returns to the campaign map.

What is NOT saved: mid-mission match state (unit positions, economy, timers, objective progress). A loaded mission restarts from its briefing. That in-mission serialization is the separate #315 epic; the schema reserves a missionState object and a schemaVersion gate for it, so #315 can add the blob without a breaking format change. A file whose schemaVersion is newer than the running build is reported as unusable (never mis-parsed), and a corrupt slot shows as CORRUPT rather than crashing the menu.

2. Creating a new mission, step by step

  1. Pick id + filename. File name must be <id>.json. Conventions: mainline <faction>_m<N>.json (russia_m4), side mission <faction>_a<act>_<slug>.json (usa_a2_black_box_recovery), finale united_finale.json. Chain order within an act is ordinal string order of the id, so usa_a2_... sorts before usa_m2 inside act 2 (a < m).
  2. Set faction, enemy, act, missionType. faction must be usa/russia/china to join a chain (anything else goes to the finale group). enemy must be a roster in data/factions/ (meridian, insurgent_proxy, proxy_coalition, ... exist). A missing faction degrades to a fallback roster with a logged warning (ResolveFactionOrFallback), it does not crash. Act 4 has a gate side effect: every act-4 mission you add becomes a requirement for unlocking the finale. An act-5 faction mission (like russia_m5) does NOT gate the finale.
  3. Author or pick a map, set mapId (section 6). Keep the inline map {name,biome,w,h} block as the fallback.
  4. Write the briefing - 3 beats: SITUATION / OBJECTIVE / COMPLICATION (see usa_m5). The briefing panel truncates vertically; 5-7 paragraph briefings are a known problem (#1201).
  5. Write typed objectives (section 3), dialog (section 4), intro rosters (unit ids must exist in the named faction JSONs), victory[] and defeat[] lines.
  6. Script the triggers (section 5). Every trigger-scripted mission needs BOTH a reachable win (endMission win / completeMission) and a reachable loss (failMission, or the board loss: the player's HQ dying still ends the mission).
  7. Validate (section 7), then run the Unity gates.

3. Objectives

objectives accepts two shapes (ObjectiveListConverter); author the typed Schema-B object form:

{
  "objectiveId": "obj_snatch_handler",
  "type": "primary",
  "targetRef": "handler_railcar",
  "description": "Breach the railcar and secure the handler alive",
  "failCondition": "Handler's railcar destroyed",
  "completionTrigger": "t_m5_handler_snatched"
}
  • Parsed keys: objectiveId/id, description/displayText/text, type (default primary), targetRef/target, optional, primary. type: "bonus" implies optional.
  • failCondition and completionTrigger are NOT parsed - they are authoring documentation. Objectives change state ONLY via setObjective/completeObjective/failObjective actions.
  • Bare-string objectives get auto ids i0, i1, ... Triggers may reference an objective by its authored id, by index key (i1), or by 1-based alias (o2).
  • primary objectives drive the "OBJECTIVE COMPLETE" announcer and the legacy primaryDone flag; bonus objectives get the "secondary complete" line.
  • HUD text rules: description is the tracker line - keep it one line, imperative, ALL-CAPS tag prefixes read well ([AIR] Destroy the ... battery, see usa_m5). The briefing joins all objective texts on one row, so keep them short. showObjectiveUpdate toasts truncate at 64 chars.

4. Dialogue

Each entry in dialog[] (MissionDialog):

Field Meaning
id Handle used by showDialog/playDialog actions and the choice-trigger convention
speaker Callsign shown on the lower-third box (e.g. "HAWKEYE", "SOKOLOVA")
portrait Portrait key for the radio box
faction Frame colour key: usa / russia / china / meridian / oracle
text The subtitle line
vo Optional VO clip key: resolves Resources/audio/campaign/<vo>.wav (missing clip = subtitle only)
postPause Optional silence after the clip in seconds (0.2-3.0); omit for punctuation-aware defaults
choices Optional 2-entry branch; blocks auto-advance until the player picks

VO naming: put wavs at unity/Assets/Resources/audio/campaign/<key>.wav and name keys after the dialog id (russia_m5_brief1 -> russia_m5_brief1.wav). Trigger playVO/playVoiceover uses the same folder.

Choices: picking choice N fires the trigger whose id is exactly <dialogId>_choice<N> (CampaignUI.Dialog.cs OnDialogChoice -> DebugFireTrigger). The choice trigger's event field is never polled - the id string IS the wiring. Example from usa_a2_black_box_recovery: dialog usa_a2_choice_prompt + triggers usa_a2_choice_prompt_choice0 / _choice1. The UI reads each choice trigger's net addDistrust and tags the button with its trust stake (#1011).

Distrust: addDistrust amounts are clamped onto a persistent 0..100 meter; the Act-V ending gate is >= 60. Keep per-choice deltas within +/-25; shipped content uses -12..+20 (usa_m5 purge = +15, extract = -12).

Delayed consequences (#1201 guidance): don't make every choice immediate meter math. Latch a per-mission flag now (setFlag path_assault) and pay it off later with flag-gated triggers - the usa_a2 pattern: choice0 sets path_assault + spawns a garrison wave labeled waveId: "clearing_garrison"; a later waveDefeated trigger gated on {type:"flag", flag:"path_assault"} completes the branch objective and spawns alerted crash-site defenders. Per-mission flags reset at InitRunner. For a payoff in a LATER mission (#1201 rec 3), set the flag with scope: "campaign" ({type:"setFlag", flag:"usa_a2_went_loud", value:true, scope:"campaign"}) - it persists via CampaignProgress and a {type:"flag"} gate in any later mission reads it (section 5). Use addDistrust for a cumulative cross-mission meter; campaign flags are for discrete remembered choices. Shipped arc: usa_a2 assault/stealth choice -> usa_a2_went_loud/usa_a2_went_quiet campaign flags -> paid off in usa_m3 (loud = extra Meridian drone response; quiet = free drone-intel bonus). An unset flag = neutral baseline, so out-of-order play / replay is safe.

Dialogue audio behavior (automatic, #1356/#2595). The mix and spoken scheduler handle it:

  • Music ducks ~-10 dB and combat SFX ~-4 dB whenever a dialog vo clip is playing, then releases over ~1 s after it ends, so radio chatter stays intelligible over a firefight or the score. The duck multiplies onto the player's music/SFX sliders and the combat-intensity music arc - it never overwrites them, and it is campaign-only (skirmish has no dialog VO, so its mix is unchanged).
  • One spoken-narrative channel. Mission announcements, campaign dialogue, EVA alerts, and commander lines serialize through one bounded priority queue. Paired playVoiceover and playDialog actions with the same clip deduplicate automatically instead of restarting the recording. Unit barks yield while scheduled narrative is active.
  • VO-length-aware pacing. A line with a vo clip HOLDS until the clip finishes (plus a 0.65-0.9 s beat and its typewriter reveal), never cutting itself off; only a line with NO vo uses the read-time timeout. Pacing implication: a long VO line (e.g. a 25 s monologue) holds the box for its full length before the next queued line shows - do NOT assume a fixed on-screen time when you chain a long VO line into the next beat. A choices[] line still holds indefinitely until the player picks.
  • Priority without overlap. Critical EVA alerts outrank ordinary announcements and commander chatter, but never create a second simultaneous voice. Expired low-value alerts are dropped rather than spoken late. Mission-start announcements complete before the first briefing line.
  • Pronunciation. Future generated recordings must apply unity/tools/campaign_voice_pronunciations.json; subtitles retain the authored spelling. Run python3 unity/tools/audit_campaign_voice.py --strict after changing campaign dialogue or clips.

5. Triggers

Three authored schemas are accepted and normalized by ScriptedTriggerListConverter: Schema A (canonical: id, event string, condition object, actions[]), Schema B (triggerId, event object with inline qualifiers, conditions[] typed gates), Schema C (event string + top-level targetRef/zone/timerId/timerSeconds/hpThreshold, once:false = repeating, offsetSeconds folded onto every action's delay). Author new content in canonical Schema A only. A malformed trigger is logged and skipped individually; it never blanks the mission.

{ "id": "t_m5_handler_reached",
  "event": "reachArea",
  "condition": { "target": "zone_handler_car" },
  "actions": [
    { "type": "showDialog", "ref": "russia_m5_handler_reached" },
    { "type": "setObjective", "id": "obj_infiltrate_yard", "state": "complete" } ] }

Triggers fire once then deactivate unless repeating: true (repeating timers re-base off the last fire). Fire order inside a trigger is action order; delay (seconds) defers a single action.

Event vocabulary (NormalizeEvent + Evaluate)

Event Fires when Condition keys
missionStart Once at BeginPlay, before the first tick -
reachArea (aliases unitEntersZone, escortReachesWaypoint) A qualifying unit is within radius (default 5 cells) of the resolved point. tag naming a registered entity restricts to THAT unit; player_*/*_lead/*_squad or no tag = any player unit target (zone/point ref), gx/gy, radius, tag
timerElapsed seconds since mission start, OR since the trigger named in after fired. With only a timerId (no seconds) it routes to the named-timer expired edge seconds, after, timerId
timerExpired A named startTimer countdown crossed 0 (edge, once per arm) timerId
unitDestroyed count form: live player units <= count. Named form: no live unit matches target/tag, guarded so it fires only after the unit was SEEN ALIVE (no tick-0 false positive) count or target/tag
unitHealthBelow A watched unit drops to <= threshold HP fraction (default 0.4); edge-detected tag/target, threshold
buildingCaptured A matching building's faction becomes the player's (edge per building instance) target (strategicPoint id/type, building id, handle)
buildingDestroyed No live matching building remains - only after it was seen alive (same guard as unitDestroyed) target
buildingDamaged A matching building drops to <= threshold HP fraction (default 0.5); edge target, threshold
objectiveComplete Watched objective state == complete (empty id = the primary objective) objectiveId/target
detected Stealth detection level >= level (min 1). Needs stealth.enabled; escalation also spawns stealth.detectionAlarmsSpawn[level-1] level
wave Timer semantics (a wave is a timer-gated spawn; escalating schedules = several wave triggers) seconds, after
waveDefeated (#1201) Every unit of the labeled spawn cohort is dead/removed. The wave must have SPAWNED first (never-spawned or empty waves stay dormant); a re-spawn under the same waveId reinforces the cohort - ALL members must die. Fires once event waveId -> condition.target
missionVictory / missionDefeat Never board-polled. Fired explicitly by EndMission (#974) as end hooks: their showDialog texts are collected into the cutscene closer, playVO plays immediately, other actions run. offsetSeconds collapses to ordering
dialogChoice Never polled at all - the trigger fires by the <dialogId>_choice<N> id convention (section 4). The event object is documentation only

State-flag refs are a special target form: a target/tag/objectiveId ending _threshold, _lost, or _recaptured is evaluated as runtime state, never geometry - distrust thresholds read CampaignProgress.Distrust >= 60, convoy/escort *_threshold = escort dead, *_lost = any objective failed, *_recaptured = any objective complete (CampaignUI.Binding.cs EvalStateFlag).

Action vocabulary (RunAction)

Action Fields Semantics
showDialog / playDialog ref Queue a dialog line by id (lower-third radio box)
spawn / spawnWave units[] (or unitTemplate + count), id (anchor), gx/gy, faction, ally, waveId, delay Spawn units at the anchor (rows of 3). Default owner = the mission enemy; faction overrides; ally: "<roster>" fields THAT roster's prefabs OWNED BY THE PLAYER (controllable, "REINFORCEMENTS ARRIVED"). Hostile spawns auto-push the player HQ + fire the under-attack alarm. waveId labels the cohort for waveDefeated. A non-az_/zone_/wp_ id registers the group as a named entity handle
killUnit / removeUnit id Scripted death: despawn by handle or unit-def id, with explosion FX (narrative deaths, e.g. Sokolova)
setObjective id, state complete / fail / reveal / active (reveal+active announce "NEW OBJECTIVE", no complete VO)
activateObjective / completeObjective / failObjective objectiveId Schema-B synonyms of setObjective
showObjectiveUpdate text HUD toast (truncated at 64 chars)
setFlag flag, value (default true), scope Latch a branch flag. Default scope (""/"mission") = per-mission (resets each mission). scope:"campaign" persists it via CampaignProgress across missions + restarts (#1201 rec 3) for a later-mission payoff
playVO / playVoiceover clip/voId Play Resources/audio/campaign/<clip>.wav on the dedicated VO source
playMusic clip Swap the music stem (no-op if unregistered)
addDistrust amount Persistent campaign distrust delta (clamped 0..100)
endMission result: win/victory else lose Force the outcome next frame; fires the end hooks, flushes queued dialog
completeMission / failMission - endMission win / lose synonyms. failMission reason is flavour-only
startTimer id, duration (or seconds) Arm a named countdown for timerExpired/timerElapsed-by-id
stopTimer id Cancel a countdown so its timerExpired never fires
setPosture posture (garrison|assault), waveId (optional cohort), leash (optional) #1357b Flip ALREADY-SPAWNED units' posture at runtime (alarm escalation). waveId targets that labeled cohort; no waveId = every unit hostile to the player (mission-wide). assault sends them charging the player HQ (readable-cause alarm beat); garrison re-holds them at their current cell
revealMap, lockdownMap, enableExfil, setUnitDisguise - Logged no-ops (presentation verbs). Never make a gameplay-critical step depend on them

Condition gates (ConditionGateMet, AND-ed with the event)

Gate Shape Semantics
objectiveState {"type":"objectiveState","objectiveId":"...","state":"active|complete|fail"} Watched objective must be in that state (active = not complete and not failed)
distrust {"type":"distrust","comparison":"below"|"atLeast","value":N} below = Distrust < N (high trust); atLeast = Distrust >= N. Legacy distrustValue with op >=/< folds onto this; op:"eq" is a no-op
flag {"type":"flag","flag":"<name>","value":true} Branch gate. As the singular condition with no event qualifier it fires PURELY on the flag; alongside a real event it is an AND gate. Resolution order (ResolveFlag, #1201 rec 3): mission-first, then the persistent CAMPAIGN flag of the same name - so one {type:"flag"} gate reads a within-mission latch OR an earlier mission's scope:"campaign" choice; a mission flag SHADOWS a campaign flag of the same name. Unset in both = false = baseline
detectionLevel {"type":"detectionLevel","value":N} Coarse: treated as detection >= N regardless of authored op
timerValue {"type":"timerValue","timerId":"..."} Coarse: only the timerId is carried; no numeric comparison is evaluated

Named-point resolution (TryResolveCell, most specific wins)

explicit gx/gy -> map spawnPoints[].id (az_*) -> map zones[].id (zone_*/wp_*) -> strategicPoints[].id -> strategicPoints[].type -> registered entity's live cell -> playerHQ / enemyHQ. An unresolved ref degrades to a logged no-fire; the bind pass prints [MissionRunner] <mission>: bound X refs, Y unresolved (list) at mission start - read that line.

6. Map authoring for missions

Maps live at unity/Assets/StreamingAssets/data/maps/<mapId>.json (MapDescriptor). Mission-relevant parts:

  • spawnPoints[] {id, gx, gy, label, faction}. Two kinds by naming convention:

    • Playable starts: any id NOT prefixed az_ (MapLoader.IsPlayableSpawn). These are army start pads.
    • az_* anchors: spawn zones for scripted spawn/spawnWave actions only; excluded from start resolution.
    • PITFALL - single playable spawn: a campaign mission always resolves 2 army starts (GameController: n = mission != null ? 2 : ...). MapLoader.ResolveSpawns uses authored starts only when playable.Count >= n; otherwise it falls back to ComputeStarts() for ALL armies - so a map with one playable spawn (e.g. usa_m5_empty_quiver, only p1_usa_start) has its authored player start IGNORED and both bases land on procedural ring positions. Author at least 2 non-az_ spawnPoints (player first, enemy second). MapLoader.ValidateConnectivity A*-checks every playable pair and warns on disconnects.
  • resourceNodes[] {type: "ore"|"derrick", gx, gy, rx, ry}. Ore blobs and capturable oil derricks (derricks snap to the nearest reachable cell, #607).

  • strategicPoints[] {id, type, gx, gy, label, hp, failsOnDestroy} - what each type ACTUALLY spawns at runtime:

    type Runtime result
    garrison Neutral civilian garrisonable building (2x2, 700 hp, cap 5) via PlaceGarrison. Placement rules: buildable land (not water/bridge/rock/road/ore), >= 11 cells from both bases, >= 9 from other garrisons
    repair Repair pad (snap-to-reachable)
    barrels Cluster of 4 explosive barrels (authored clusters REPLACE the procedural scatter)
    fuel_depot Cluster of 6 barrels. Place on a bridge APPROACH, not the span (soft-blocks the crossing)
    tech / tech_objective CURRENTLY NOTHING SPAWNS - open issue #1283. hp/captureRadius parse but are never read, so buildingCaptured/buildingDestroyed triggers targeting these ids can NEVER fire (russia_m4 and usa_m5 wins are unfireable today). The pending fix spawns a real enemy-owned, capturable/destroyable building EntityInstance at the cell, honoring authored hp. Until it merges, do not hang a win condition on a tech_objective point

    A strategicPoint id is also the campaign building handle: the bind pass registers whatever building stands on its cell, and BuildingMatches resolves building* trigger targets by SP id, SP type, or raw building id.

  • zones[] {id, x/y (or gx/gy), radius, label} - circular trigger areas. Naming: zone_* for reach/detect areas, wp_* for escort waypoints. Zones not referenced by triggers are fine (descriptive), but every zone a trigger references must exist.

  • overlayTiles[] {gx, gy, tile} - per-cell tile overrides applied after the generator (water/bridge also lower elevation; rock raises it). terrainRows is the full-authored alternative (with generator: "none"); seed > 0 makes generation deterministic.

7. Validation checklist before shipping a mission

Cheap checks first (a throwaway Python validator in your session scratchpad directory - NOT committed to the repo - is the established pattern):

  1. JSON parses (python3 -c "import json; json.load(open(...))") for the mission AND the map.
  2. ASCII only - the campaign files must stay plain ASCII (use \u2014 escapes if you must).
  3. Vocabulary check - every event and action type appears in the section-5 tables. Anything else arms but stays dormant (logged "unhandled event ... dormant").
  4. Ref resolution - every showDialog ref exists in dialog[]; every objective id referenced by actions/gates exists in objectives; every az_*/zone_*/strategicPoint ref exists in the map JSON; every choice dialog has its <dialogId>_choice0 and _choice1 triggers.
  5. Rosters - every unit id in intro.* and in spawn/spawnWave actions exists in the roster faction's JSON (data/factions/<faction>.json); check ally/faction spawns against THAT roster, not the enemy's.
  6. Timers - every timerExpired/timerElapsed-by-id watcher has a startTimer that can run before it; stopTimer ids match.
  7. Win AND lose both reachable - trace at least one action path to endMission win and one to failMission/endMission lose (a triggered mission disables the clear-the-map auto-win).
  8. Wave sanity - every waveDefeated waveId is spawned by some reachable spawnWave first.
  9. Unity gates - unity/compile-check.sh for any code change; unity/run-tests.sh runs the campaign logic checks (Campaign_AllMissionsLoad, Campaign_TriggerLists_NonNull, Campaign_Binding_*, unlock/act/music checks in Build.RunLogicChecks); unity/build-game.sh is the merge gate.
  10. Runtime smoke - start the mission and read the log: [Campaign] loaded N, skipped 0, [MissionRunner] armed K trigger(s), and the bind summary bound X refs, 0 unresolved.

Starting economy (startingBase) - #1339

By default a campaign mission spawns the full legacy FOB (Construction Yard + power + refinery + barracks + war factory) and the faction's default starting cash (5000). Author an OPTIONAL startingBase object to take full control of a mission's economy:

"startingBase": { "mcv": false, "credits": 0, "buildings": [] }
  • Omit startingBase entirely = legacy behavior, byte-identical to before: full FOB + default cash. This is the default for every existing mission - do not add the key unless you want to change the economy.
  • Present startingBase = the mission OWNS its economy; all three sub-fields are honored:
    • buildings - stamp exactly these at the player start. Each entry resolves as a building id first, then as a role key (power, refinery, barracks, war_factory, tech, construction_yard, superweapon, ...) against the PLAYER faction roster. An empty list = NO base at all (a pure infantry/stealth insertion). Keep lists short and ordered; entries stamp on a simple 3-wide grid at the start location.
    • mcv - true spawns the roster's mcv unit at the start so the player unpacks their own base.
    • credits - the starting cash. 0 means broke - an authored startingBase with credits:0 starts the player with zero cash; it does NOT fall back to 5000. Omit the whole startingBase object if you want the default cash.
  • Defeat semantics (READ THIS for no-base missions). Engine defeat derives from Construction-Yard loss. A startingBase with no CY has NO engine loss condition, so the mission MUST author its own reachable lose path and you must verify at least one loss trigger is reachable regardless of player unit count (see section 8).

8. Known limitations and open issues

  • #1283 - tech_objective spawns nothing. building* triggers targeting those points can never fire (the seen-alive guard requires a live building first). russia_m4 / usa_m5 win paths are dead until the engine fix lands. Also flagged there: CI has NO campaign-binding logic check that building* targets resolve to live map.buildings after world build - the unresolved-ref summary only proves the id exists in the map JSON.
  • dialogChoice id fragility. The choice wiring is a raw string convention (<dialogId>_choice<N>). Renaming a dialog id silently orphans its branch triggers (the runtime only logs "no follow-up trigger ... flavour-only").
  • Coarse ops. detectionLevel gates always evaluate >=; timerValue gates carry only the timerId with no comparison; distrustValue op:"eq" is a no-op.
  • Presentation-only verbs. revealMap, lockdownMap, enableExfil, setUnitDisguise are logged no-ops (#1201 recommends implementing or clearly renaming them).
  • No-base missions have no engine-driven defeat (#1339). Engine defeat derives from Construction-Yard loss, so a mission that authors a startingBase without a construction_yard (empty buildings, or a buildings list with no CY) can never lose by engine and MUST author its own reachable lose path (a timerElapsed/timerExpired, detected level, last-unit unitDestroyed, or buildingDestroyed trigger whose actions end the mission in a loss). Such a mission's map must also NOT place an enemy construction_yard the player can destroy - razing the only CY on the board while holding none forces a false DEFEAT (OnFactionEliminated). Future CI idea: a Campaign_NoBaseNeedsLosePath check that flags a no-CY startingBase mission lacking a scripted loss trigger (or an enemy CY on a no-base map).
  • Briefing truncation. The briefing body uses vertical truncation; long briefings get cut. Keep to the 3-beat rule.
  • Immediate trust tags. Choice buttons show the trust stake up front (#1011), which can turn moral choices into meter optimization - prefer delayed/tactical consequences (#1201). Cross-mission payoffs are now supported (#1201 rec 3): persist a choice with setFlag scope:"campaign" and gate a later mission's triggers on it (section 5) instead of surfacing an immediate distrust delta.

9. Future expansion

  • New factions / DLC chains. A mission's faction field plus Campaign.FactionOrder is the extension point: add the faction key to FactionOrder (and a roster JSON registered by GameData.Load) and its missions form a new chain automatically; unknown factions already fall back safely (finale group / ResolveFactionOrFallback).
  • Explicit ordering (#1201). Chains are ordered by act then ordinal id, which is fragile when several missions share an act (USA act 2/3). Proposal: add order (or requires) to MissionDef, keep act for presentation/escalation, and use explicit ordering for unlocks.
  • Schema consolidation. New content should be Schema A; Schemas B/C exist only for legacy missions. Long-term: migrate the authored files and add a Campaign_SupportedEventVocabulary logic check so an off-vocabulary event fails run-tests.sh instead of going dormant.

10. Enemy posture (garrison vs assault) - #1357

By default every campaign enemy spawns and immediately charges the player HQ (SetupMission orders the intro raiders at PlayerHQWorld(); DoSpawn does the same for hostile waves). That reads as nonsense when the force is site security standing on top of the thing it is guarding. Posture lets an authored force HOLD its ground for a readable reason instead.

Design rule: holds need a reason, attacks need a cause

  • Garrison a force when it is defending a fixed thing (a launch complex, a compound, a bridgehead, an AA site). It anchors where it spawns and fights only what comes to it.
  • Keep a force assault only when there is a readable in-fiction CAUSE for it to move on the player - an alarm tripped, a countdown/QRF timer, a story beat, a counterattack the player provoked. If you cannot name the cause, garrison it.

Fields

  • enemyPosture (MissionDef, top-level string; default "assault"). Posture for the whole intro.enemyUnits force. intro.enemyUnits is a bare id list with no per-unit fields, so its posture is set once here. "garrison" = the intro force holds the enemy base/site (each unit anchors at its own spawn cell).
  • posture (per spawn / spawnWave action; default "" = assault). "garrison" = the spawned hostile units hold their spawn cell. Ignored for ally: spawns (player-owned reinforcements are always player-controlled).
  • leash (per spawn / spawnWave action; cells; default 0 = unit sight + 2). Garrison engagement/return radius, measured from the ANCHOR (the spawn cell), NOT from the unit. A garrison unit engages only targets within leash of its anchor, never chases beyond it, and walks back to the anchor (re-facing its spawn heading) when nothing is in-leash.

Semantics

  • Anchor = the unit's spawn cell (intro: its SetupMission cell; wave: its DoSpawn cell). The leash is anchor-relative, so a garrison unit can push a few cells to meet an intruder but always returns.
  • A garrison unit at low HP retreats to its anchor (not to a base it may not own).
  • Posture is spawn-time state on the unit; nothing else re-orders idle campaign enemies (aiEnabled=false), so a garrison unit with no in-leash target holds.
  • Default is assault everywhere, so a mission that sets neither enemyPosture nor an action posture behaves exactly as before.

Example (usa_m5 "Empty Quiver")

"enemyPosture": "garrison",          // intro perimeter guards HOLD the launch complex
// perimeter reaction stiffens the site defence (holds); it does not charge the FOB:
{ "type": "spawnWave", "waveId": "perimeter_react", "faction": "meridian",
  "posture": "garrison", "spawnZoneId": "az_complex_perimeter", "unitTemplates": [ ... ] }
// the QRF surge + extract counterattack STAY assault - they have a readable cause
// (the fueling-clock timer / the player choosing to extract the CHIMERA drive).

The mission stays winnable/losable: the player is forced to attack by the launch countdown, and no objective depends on the enemy coming to the player.

Guard-objective posture (posture: "guard") - #1357b

A guard force is a garrison (holds its spawn cell within leash) that ALSO watches one building and CONVERGES on it when it takes damage.

  • guardRef - the building/SP id the force guards (matched by BuildingMatches: a strategicPoint id/type at the building's cell, an entity handle, or a def id).
  • responseRadius (cells; default leash + 4). Only guards whose anchor is within this radius of the guarded building respond.
  • Converge = while the building's lastDamagedTime is within a 6 s window, responders drop the anchor leash and engage anything within responseRadius of the building, then fall back to their anchor when the window lapses. The damage signal is the existing single DamageBuilding sink (EntityInstance.lastDamagedTime) - one field read per guard per tick, no polling of all buildings, no new hook.
  • Use it for a force defending a fragile objective (a reactor you must take INTACT): a careless AOE hit on the objective wakes its guards; a clean capture (no HP loss) does not.

Patrol posture (posture: "patrol") - #1357b

A patrol force walks a fixed 2-4 waypoint loop when idle and engages within leash of the patrol PATH (nearest point on the polyline), NOT of a drifting anchor.

  • patrol - an array of 2-4 waypoints, each an explicit { "gx": N, "gy": N } cell OR a named { "point": "<ref>" } (resolved by TryResolveCell). Fewer than 2 valid waypoints degrades safely to a plain garrison hold at the spawn cell.
  • Anti-kite: because the leash reference is the fixed path (not the unit's position at acquisition), a player can pull a patroller at most leash off its route before it drops the target and resumes - there is no ratchet where re-acquisition drags it toward the bait.
  • Author waypoints within ~`2 * leash` cells of each other so the leash disks overlap into a continuous engagement corridor along the route.

Runtime posture flip (setPosture action) - #1357b

setPosture is the readable-cause ALARM mechanism: an authored beat (a gate destroyed, a detection threshold, a timer) flips ALREADY-SPAWNED units at runtime so a holding garrison SORTIES at the player.

  • posture - "assault" (default) sends each target charging the player HQ (and fires the under-attack announcer); "garrison" re-holds each target at its current cell.
  • waveId - flips only that labeled cohort (the spawnWave waveId, tracked by waveUnits). Omit it to flip EVERY unit currently hostile to the player (mission-wide).
  • leash - optional new leash (cells) for a garrison flip.
  • Design rule holds: never flip TO assault without a cause the player can read. Pair it with the event that supplies the cause (buildingDestroyed <gate>, detected, timerElapsed).

Pilot: russia_m4 "Tsar's Gambit" (garrison + alarm)

The reactor complex demonstrates the loud/quiet fork:

  • enemyPosture: "garrison" - the ALERT intro force HOLDS its corner instead of charging.
  • A missionStart trigger spawns 2 reactor guards (posture: "guard", guardRef: "reactor_core", leash: 4 at cell 53,54 - the fortified SE screen) and 2 ORACLE garrison guards (leash: 5 at 70,30). Leash 4 from 53,54 leaves the reactor's OPEN west capture ring (49, 50-53; nearest cell 49,53 is ~4.1 cells > leash 4) uncovered, so a Spetsnaz engineer coming from the tunnel exit (38,50) along gy=50 reaches the ring untouched - the QUIET path is tense (a guard sits ~4 cells away) but viable.
  • The existing gate alarms (buildingDestroyed perimeter_gate_north/south) gain setPosture{assault} (mission-wide): breaching a gate sends the whole complex - reactor guards, ORACLE guards, and the intro reserve - charging the player. The LOUD path trades the quiet advantage for a fight with a readable cause.
  • Both win (buildingCaptured reactor_core) and lose (buildingDestroyed reactor_core) chains are untouched; capture is HP-neutral so guarding never blocks the intact seizure.