MISSION SCRIPTING

The shared script libraries

The scripts/lib folder holds plain Lua helpers that several missions share. They use only the public API, so you can read them as worked examples. Load one with require("lib.<name>").

modulewhat it gives you
lib.mission_libconstant guards, named variable scopes, small helpers
lib.flowa step graph: steps that wait on facts, with checkpoints
lib.campaigna full linear mission built from a content table, on top of lib.flow
lib.combatpick the cheapest combat task group

lib.mission_lib

local lib = require("lib.mission_lib")
functionwhat it does
lib.one(value, name)returns value; errors with name when it is nil
lib.list(...)returns the arguments as a list; errors on any nil
lib.scope(context, state, tag)returns a scope object; see below
lib.timer_name(tag, elapsed)returns the timer name without tag., or nil when another tag owns it
lib.is_slot(context, event, slot)true when the event names this slot
lib.place_all(context, squads, mode)places each squad with one mode
lib.activate_scenes(context, scenes)activates each scene
lib.play_idles(context, idles)calls play_performance for each {sensor, state}

Scopes

A scope puts a prefix on every variable and timer name, so two parts of one script cannot clash.

local scope = lib.scope(context, state, "port")
scope:set_variable("seed", 42)      -- stored as "port.seed"
local seed = scope:variable("seed")
scope:start_timer("wave", 45000)    -- timer "port.wave"
scope:cancel_timer("wave")
scope:clear_variable("seed")

In on_event_timer_elapsed, map the full name back:

local name = lib.timer_name("port", event.timer_name)   -- "wave", or nil

lib.flow

A flow is a list of steps. A step starts when the steps before it are done. It runs an action once, then waits until its condition is true. All progress is stored in mission variables, so a flow survives a reload.

Build a flow

local flow = require("lib.flow")

local graph = flow.new{
    key = "intro",                         -- prefix for every stored name
    facts = {
        {id = "door_used", observe = function(context, state, event)
            return lib.is_slot(context, event, mission.Slot.O_DOOR_SWITCH)
        end},
    },
    steps = {
        {id = "arrive", await = flow.fact("door_used")},
        {id = "open", after = {"arrive"}, run = function(context, state)
            context:slot(mission.Slot.D_DOOR):transition{
                transition = context.sdk.device_transitions.open,
            }
        end},
    },
}

Step fields

fieldtypemeaning
idstringunique step name
afterlist of step idssteps that must be finished first
whenconditionextra start condition; checked only before the step starts
runfunction(context, state)runs once, when the step starts
awaitconditionthe step finishes when this is true; nil means at once
checkpointbooleana restart replays from here; see below
reset_factslist of fact idsfacts to forget when this checkpoint replays; only on a checkpoint

Conditions

A condition is one of:

  • nil: always true.
  • flow.fact(id): true once that fact was seen.
  • flow.all(a, b, ...): all are true.
  • flow.any(a, b, ...): at least one is true.
  • a function function(context, state) return <boolean> end.

Facts

A fact is a flag that latches. Its observe(context, state, event) runs for every event passed to handle. Once it returns true, the fact stays true for the attempt. An event that arrived before its step started is not lost.

Drive a flow

return {
    on_start = function(context, state) graph:advance(context, state) end,
    on_load = function(context, state) graph:advance(context, state) end,
    on_event_player_trigger = function(context, state, event)
        graph:handle(context, state, event)
    end,
}
methodwhat it does
graph:advance(context, state)starts and finishes every step it can; returns true when all are finished
graph:handle(context, state, event)records facts from the event, then advances
graph:started(context, state, id)true once the step has started
graph:fact(context, state, id)true once the fact was seen
graph:finished(context, state)true when every step is finished
graph:cancel(context, state)stops the flow for this attempt

advance walks the steps in dependency order in one pass. A step that finishes lets the next step start in the same pass.

Attempts and checkpoints

The flow stores which attempt owns it. On a new attempt:

if (previous_attempt_reached_a_checkpoint) {
    clear(checkpoint_step);
    clear(every_step_that_depends_on_it);
    clear(checkpoint.reset_facts);
} else {
    clear(all_steps);
    clear(all_facts);
}

So a checkpoint step and everything after it run again. Steps before it stay finished.

Limits

limitvalue
facts per flow64
steps per flow64
entries in one after, all or any list64
condition nesting depth16
stored name length63 bytes, flow.<key>.<kind>.<id>
variables usedone per fact, one per step, plus 3

Keep key and ids short. Use two flows when one would pass 64 steps; lib.campaign does this.

lib.campaign

A campaign builds a whole linear mission from one content table. The Red War drafts use it.

local campaign = require("lib.campaign")
local unit, line, move = campaign.unit, campaign.line, campaign.move

return campaign.new{
    key = "deadzone",
    directive_sensor = Slot.M_DIRECTIVE_SENSOR_80B9AF4B,
    dialogue_sensor = Slot.M_DIALOG_SENSOR_80B9AF4B,
    legs = {
        {id = "town", state = mission.states.STATE_80B9AD31_0033_0000_80B9A87B,
            arm = {Slot.PT_ELEVATOR_TOWN}},
    },
    steps = {
        {id = "town", directive = Directive.RENDEZVOUS_WITH_HAWTHORNE,
            navpoint = Slot.AP_ELEVATOR_TOWN,
            ends = {trigger = Slot.PT_ELEVATOR_TOWN}},
    },
    encounters = {
        {id = "devrim", squads = {unit(Squad.SQ_DEV_LUZ, Slot.SQ_DEV_LUZ)}},
    },
}

Content table

fieldmeaning
keyflow key; keep it short
directive_sensorthe type-68 slot that shows goals
dialogue_sensorthe type-53 slot for lines; needed when any step has lines
legslist of {id, state, arm}; the first leg is where the player spawns
stepsthe goals, in order
encounterssquads to place, each after a step
introoptional list of {state, cinematic} cutscenes played before the first leg
spawn_setoptional spawn set hash for the arrival and every host move
finishoptional function(context) run before the mission completes
omitoptional list of type-4 slots to keep out of every seed

A leg’s arm lists the type-31 triggers to arm when the player enters that leg’s region.

campaign.new builds initial_state for you. It opens on the first intro state when there is one, otherwise on the first leg’s state, and it carries spawn_set and the omit list. The omit list also gets every object a step ends on with interact.

Opening cutscenes

Each intro entry is one cutscene in its own authored state, played in list order before the first leg:

intro = {
    {state = mission.states.STATE_CINE_A, cinematic = Slot.PF_OPENING_CINEMATIC},
},

What the library does:

  1. on_start holds the player’s spawn, so no body exists while the cutscene plays.
  2. Entering a cutscene’s region starts that cutscene.
  3. When a cutscene ends, is skipped or is refused, the next one is selected. After the last one, the first leg’s state is selected and the spawn is released.
  4. A reload while a cutscene is marked playing ends it instead of replaying it.

A mission with no intro never holds the spawn.

Step fields

fieldmeaning
idstep name
directivea mission.Directive entry to show when the step starts
navpointoptional type-47 slot for the map marker
waypointoptional type-60 volume; the marker hides inside it
lineslist of campaign.line(cue, filter) to play at start
on_startoptional function(context)
endshow the step ends; see below
barriertrue when a later trigger must not skip this step
revisittrue when the end trigger was already crossed earlier; arms it again at start

ends can hold any of:

keyends when
triggera trigger slot, or a list of them, reports
regionthe player enters that leg (a leg id)
clearthe named encounter, or list, is cleared
ghost_linka Ghost scan on that slot starts and then finishes
interactthe player uses that type-4 object
destroyedevery listed object reports gone

A step with no ends finishes at once. Unless barrier is set, a step also ends when the player reaches any later step’s trigger or region.

Encounter fields

fieldmeaning
idencounter name; ends.clear uses it
afterstep id that must start first; default "arrival"
triggeroptional trigger that must also report; it must be armed by a leg
objectiveoptional type-3 objective given to every squad before placing
squadslist of campaign.unit(squad, slot, group)
linesoptional lines to play
on_startoptional function(context)

Helpers

functionreturns
campaign.unit(squad, source, group){squad, source, group}; source is the squad’s type-1 slot
campaign.line(cue, filter){cue, filter}; filter is an optional type-60 volume
campaign.move(context, slots, name)nothing; runs transition on each device with the named transition

lib.combat

functionwhat it does
combat.same_group(a, b)true when two task groups are the same
combat.lowest_cost(event, groups, current)returns the cheapest group and known; keeps current on a tie

Use it in on_event_squad_state. See “Move a squad between task groups” in Recipes.