Skip to content

Chains and campaigns

One mission is a sortie. Several missions that follow from each other are a story. This page covers three levels of that, from pure JSON to a campaign with a memory:

  1. Chains: a mission appears only after another one has ended a certain way. JSON only.
  2. Historic missions: story beats that are guaranteed to appear, optionally on a real date. JSON only.
  3. Campaigns: follow-ups that depend on counting, remembering people or player choices. Needs a script.

Working examples: chain_convoy (a fork), chain_airfield (a straight line four deep) and campaign_kanalkampf (a campaign). See Examples.

Chaining on an outcome

A requirement of type 3, CompletedMission, makes a mission wait for another one:

{ "Name": "CompletedMission", "_requirementType": 3, "_value": "mymod_convoy_spotted,success" }

The value is a mission id, optionally followed by a comma and the outcome it must have ended with:

Value Meaning
"mymod_convoy_spotted" finished, with any outcome (including cancelled)
"mymod_convoy_spotted,success" finished successfully
"mymod_convoy_spotted,failure" flown and failed
"mymod_convoy_spotted,cancelled" called off by the player, or expired

cancelled is its own outcome: a mission the player called off does not count as a failure.

"Success" is fixed by the position of the outcome the dice picked: index 1 (gone wrong, skill passed) and index 3 (as planned, skill passed) count as a success, index 0 and 2 as a failure.

The game remembers every finish for the whole campaign. A player who failed a mission and later won it satisfies both ,failure and ,success.

Branching

Two follow-ups gated on opposite outcomes give you a fork:

mymod_convoy_spotted
   |
   +-- success --> mymod_convoy_strike
   |
   +-- failure --> mymod_convoy_search

Nothing stops you from gating several missions on the same outcome, or from chaining more steps onto the follow-ups. Requirements combine with AND, so a mission can require both a finished predecessor and a runway tier:

"_missionRequirements": [
    { "Name": "RunwayTier",       "_requirementType": 0, "_value": "2" },
    { "Name": "CompletedMission", "_requirementType": 3, "_value": "mymod_convoy_spotted,success" }
]

There is no OR. To accept either of two predecessors, write two versions of the follow-up mission.

Historic missions

A normal mission sits in a pool and is drawn at random across the week. For a follow-up that is usually wrong: the player finishes step one and then waits, possibly for weeks, possibly forever if the pool is large.

"_isHistoric": true changes that. A historic mission:

  • never enters the random draw. Once its gates pass, it is guaranteed to appear. The game checks every in-game hour, so it shows up within the hour. It never waits for the weekly schedule and never takes up one of that week's slots.
  • appears on its date when it has a _historicDay: on that day, or later if its other gates only pass later.
  • appears within the hour when it has no _historicDay: as soon as its gates pass. A follow-up gated on "<previous mission>,success" shows up within the hour after that mission succeeds.
  • never expires. _timeToStartRange is ignored, so the player has unlimited time to fly it.
  • is one-shot on success. Once won, it never appears again. A failed or cancelled attempt brings it back at the next hourly check - even while its result report is still waiting to be read - so a chain gated on success can be retried.
  • is never on the map twice. While one copy is waiting or in flight, no second copy appears.

Its gates combine with AND:

Has a CompletedMission gate Has a _historicDay Appears
no no at the next hourly check
yes no within the hour after the predecessor ended right
no yes on that day
yes yes on that day, or later - within the hour after the predecessor ended right, if that happens after the date

That combination is exactly what a story beat needs. Use it for every step of a chain except, usually, the first.

Controlling when a chain can start

"_isHistoric": true,
"_historicDay": { "Year": 1940, "Month": 8, "Day": 13, "Hour": 0, "Minute": 0 }

That date is the earliest day the mission appears: on that day if its other gates already pass, otherwise within the hour after they do. A year below 1900 counts as "no date", which is why example files carry "Year": 1 when they mean none.

To close a mission off after a certain point, use a LatestAppearanceDay requirement (type 2), counted in days from the map's starting date. It works on any mission, historic or not.

Put together, each operation becomes available on its real date, and one that was missed can be closed off.

Pitfalls

Duplicate ids. Two missions with the same id means one silently replaces the other, decided by load order. Prefix every id with your mod name, and remember to update the ids inside every CompletedMission value when you rename.

Forgetting _isHistoric on follow-ups. The chain works, but each step joins the random weekly draw once its gate passes, so it may take a few slots to surface, and a finished step can be drawn again. That is the right choice for a repeatable follow-up - one the player may fly more than once. Use _isHistoric when the follow-up must appear right away and only once.

Several historic missions due at once. They all appear together: dated ones on their day, undated ones at the same hourly check. If you want them one after another, chain them on each other's outcome or give them different days.

Chaining on a mission that never appears. A gate on a mission whose own requirements never pass, or which is tagged for a different map, silently blocks the rest of the chain. Test each step on its own before linking them.

Testing with the cheat. create_mission ignores every gate. It proves a mission loads, not that the chain unlocks. See Testing and reloading.

When JSON is not enough

A requirement can only ask about a previous mission's outcome. When the condition is something else (who flew it, what the base looks like, how many times this has already happened), a script can create the follow-up itself:

@event_handler(MissionEvent.SET_MISSION_IMPACT)
def set_mission_impact(mission_entity_id):
    if missions_api.IsMissionSuccessful(mission_entity_id):
        missions_api.CreateMission("mymod_pursuit")

CreateMission puts the mission on the war map at once, skipping the weekly draw. It still checks the target mission's own requirements and map tags: if they do not pass, nothing is created and a warning is written to Player.log. Call it from set_mission_impact, where the outcome is known; from initialize_mission it is refused.

What it does not protect you from: nothing stops a script handing out the same mission twice, and a historic mission created this way is not blocked the way the automatic spawn blocks it.

Use the JSON route wherever it fits. It survives saving and loading, the validator can check it, and it cannot hand out a mission the base cannot fly. Reach for CreateMission when the decision genuinely depends on something only a script can see.

From a chain to a campaign

A chain has no memory. Fly chain_airfield twice and it plays identically both times, because every condition in it is a single yes or no about the mission before.

A campaign remembers. game_api.GetScriptDataPath(id) gives your script a folder of its own inside the game data folder; a JSON file in it survives the flight, the day and the session.

import json
import os

CAMPAIGN_ID = "mymod_campaign"

DEFAULTS = {
    "raids_survived": 0,
    "raids_lost": 0,
    "first_blood": None,
}


def _path():
    # The folder belongs to the id, NOT to the save game. Without the save's
    # guid in the file name, a second save would continue - and overwrite -
    # the first one's campaign.
    return os.path.join(
        game_api.GetScriptDataPath(CAMPAIGN_ID),
        "campaign_{}.json".format(game_api.GetAirBaseGuid())
    )


def load():
    state = dict(DEFAULTS)

    try:
        with open(_path(), "r") as handle:
            state.update(json.load(handle))
    except (IOError, OSError, ValueError):
        pass          # no file yet, or a half-written one: start clean

    return state


def save(state):
    try:
        with open(_path(), "w") as handle:
            json.dump(state, handle)
    except (IOError, OSError):
        pass          # the mission must still fly

The id you pass to GetScriptDataPath may contain only letters, digits, _, - and ., up to 64 characters.

Three things are easy to get wrong, and all three bite later rather than at once:

  1. Give every key a default. A save written before you added a key will otherwise fail with a KeyError on load, inside a handler, mid-flight.
  2. Put the save's guid in the file name. GetScriptDataPath gives one folder per id, shared by every save game.
  3. Wrap every read and write. A campaign that cannot write its state still has to let the mission fly.

Mod scripts can import one another, so put the state in one module (say mymod_campaign_state.py in the same Missions/ folder) that every mission imports. Then they cannot disagree about what has happened.

What the memory buys you

Conditions no requirement can express:

state = campaign_state.load()

if state["raids_lost"] >= 3:
    missions_api.CreateMission("mymod_regroup")     # counting
elif state["first_blood"] is not None:
    pass                                            # remembering a person

It also lets a mission tune itself against the campaign's history, in initialize_mission:

@event_handler(MissionEvent.INITIALIZE_MISSION)
def initialize_mission(mission_entity_id):
    state = campaign_state.load()

    if state["raids_survived"] >= 2:
        # The enemy has seen this squadron before.
        missions_api.SetMissionStat(mission_entity_id, "Fight", 76, 115)
        missions_api.SetAsPlannedChance(mission_entity_id, 35)

Same mission, same JSON, measurably harder the fifth time than the first.

The campaign_kanalkampf example is the worked version: five missions, two of whose conditions are counts. The Examples page compares it with the chains.