Skip to content

Away missions

An away mission is a scene several consoles play at once, one character each. Everybody is looking at the same beat, but the scene offers each of them a different set of things to do — the doctor can read a body, the engineer can read the reactor, and neither can do the other's job.

That is authored once, as ordinary dialogue AMD, with no new syntax:

### [The Airlock](lab)
---
Speaker: outpost
---
% The inner door is open. The lights are on a low cycle.

- [Read the body by the hatch](autopsy)  if medical >= 1
- [Force the wall panel](panel_open)     if engineering >= 1
- [Check the corners first](cover)       if security >= 1
- [Move further in](corridor)

The guards read the acting character's roles. Give one console Dr Sorel (Roles: away, medical) and another Chief Ruiz (Roles: away, engineering) and the same scene draws two different menus. Every character also sees Move further in, because it is ungated — which is what keeps a menu from ever being empty.

Why this works with no new grammar

dialogue_choices(scene, agent_id, speaker) has always evaluated guards against whatever agent it is handed. The shipped comms driver passes the player ship, so everyone sees one menu. away.py passes the character.

The pieces

You need Use
The bodies on the ground an ## Away Team section of lifeforms, spawned with lifeforms_spawn(section)
Who is playing whom away_assign(client_id, lifeform)
Guards that ask about the character away_metric_install() once, at map start
The current beat away_scene_begin(scenes, key, speaker=…)
What THIS console may do away_choices(client_id)
Taking an answer away_answer(client_id, index, seq)

A character is a lifeform — a body in the world. Who the player is remains a crew post, a label on a seat. The two are linked, not merged.

Writing the screen

=== away_screen
    jump away_beam_up if not away_is_open()

    away_said = away_line()
    gui_text_area("{away_said}")

    away_seq_now = away_seq()
    for away_i, away_ch in enumerate(away_choices(client_id)):
        gui_row("row-height: 2.4em;")
        away_label = away_ch.label
        gui_button("$text:`{away_label}`;", data={"pick_index": away_i, "pick_seq": away_seq_now}, on_press=away_pick)

    on change away_seq():
        jump away_screen

    await gui()

=== away_pick
    away_answer(client_id, pick_index, pick_seq)
    ->END

Three things in there are load-bearing:

  • on_press= + data=, never an inline on gui_message block. The choices are drawn in a for loop, and a handler block registered in a loop captures the loop variable at its last value.
  • on change away_seq() is how the other consoles follow along. A signal does not wake a task sitting in await gui(); a polled revision counter cannot miss the transition. Get this wrong and everyone else's screen goes stale until something else happens to rebuild it.
  • The console hands back the seq it rendered with. That is the arbitration — see below.

Two consoles pressing at once

away_answer refuses a press whose token has moved on. The token bumps on every beat and every answer, before the outcome runs, so a second press arriving in the same frame is already stale by the time it is looked at. Two officers can press different choices in the same frame and exactly one lands, with no lock.

overlay_choice is not a substitute

It hands the whole audience one shared Promise, and Promise.set_result has no already-done guard. The first press wins across ticks, but two presses in the same frame are last-writer-wins. With six consoles able to act, that is the race you have.

One line for everybody

dialogue_pick_line picks a random eligible variant. Called once per console it tells each of them a different story, which reads as a fault in the writing rather than in the code. away_scene_begin picks the line once and away_line() gives every console the same one.

Ending a scene

A choice with an empty target ends the conversation:

- [Beam back up]()

Without one, a scene with no choices is a dead end — the screen draws its line and no buttons, and nothing ever closes. away_is_open() then goes False, and the repaint above carries each console into its beam-up label.

Showing who a character is

away_job_text(lifeform) is what a screen should print, not the raw role list:

    gui_text("$text:`{away_name}`;justify:center;font:gui-4")
    away_job = away_job_text(away_who, default="watching")
    gui_text("$text:`{away_job}`;justify:center;font:gui-2")

A lifeform carries machinery beside its job. ultra_beam is added automatically to anyone with no space-object host — that is every away-team member the moment they beam down — and the AMD loader stamps amd_lifeform:<key>. Printed raw, a medic reads medical, ultra_beam, amd_lifeform:sorel. Roles are also a set, so an unsorted list reads differently on each repaint, which looks like a bug in the mission.

Assign the whole team BEFORE rerouting anybody

gui_reroute_client ticks that client's GUI task in the same frame, so a screen that draws the whole team must not be rerouted while the team is still being built. Do it in one loop and the main screen — which sorts first, being client 0 — draws an empty roster and only fills in on the next beat. Two passes: assign everyone, then reroute everyone.

Morphing a console, and putting it back

The order matters, and is the same recipe the Control Gallery's viewer uses:

    gui_widget_list_clear()                       # a console leaves an engine widget list behind
    for t in gui_get_console_types():
        remove_role(client_id, t)                 # roles OUTLIVE the page that added them
    add_role(client_id, f"console, away")
    set_inventory_value(client_id, "CONSOLE_TYPE", "away")

Record where the console came from on the way down (CONSOLE_TYPE) and restore both the type and the role on the way back.

The role is not optional, and its absence is silent

Audience narrowing goes through any_role(). A console with the right CONSOLE_TYPE and no role simply stops receiving overlays, announce() and comms — nothing errors. Note also that add_role(cid, "console, {x}") is not interpolated: a plain string in a function argument is not an f-string. Write f"console, {x}".

The crew post, and the half of it that does not survive

A morph changes CONSOLE_TYPE, and a crew seat is believed only while that still agrees. Measured: seats=1 before the morph, seats=0 during, with the member back in crew_choices_for's free list.

The two halves come apart. The display survives — crew_post_of reads the client's inventory, which a morph does not touch, so the screen still shows the right name. The reservation does not, so a console that stayed could be assigned the same person and you get two of them. crew_seat_count() will not show you this: it counts stored seats while the liveness filter runs at resolve time. Ask crew_choices_for(ship_id).

Restoring CONSOLE_TYPE does not take the seat back either — only crew_assign does, and common_console_show does not call it. Remember the pick on the way down and re-assert it on the way back. See Crew rosters.

Testing it

Headless --test never opens a console page, so it proves the story compiles and the routes fire — and nothing about the screen. Two things that do:

  • A panel harness drives the real screen in-process: push two client pages, reroute each into the away label, and read the buttons each one emitted. Assert the two lists differ — if guard evaluation ever stops seeing the character, both are still lists, just the same one.
  • The engine, with a server-side driver that answers for a console every few seconds, so a full run — beam down, every beat, beam up — needs nobody to click.

For engine diagnostics use logger(name=…, file=…) from MAST, then log(msg, name). The engine hands back no stdout, so print is invisible there; and logger only attaches its file handler when a MAST task exists, so calling it from script.py is silently a no-op.