diff --git a/.gitignore b/.gitignore
index b5697677619..a7b20833632 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@
.project
*.code-workspace
.lux/
+modules/missions/.editor/
diff --git a/modules/missions/gadgets/mission_loader.lua b/modules/missions/gadgets/mission_loader.lua
index afe9a4e968f..1d3f1f053ad 100644
--- a/modules/missions/gadgets/mission_loader.lua
+++ b/modules/missions/gadgets/mission_loader.lua
@@ -232,6 +232,7 @@ local function loadMission(missionName)
syncWatchedCallins()
activeMission = missionName
Spring.SetGameRulesParam("mission_active", 1)
+ Spring.SetGameRulesParam("mission_name", missionName)
Spring.Echo("[" .. LOG_TAG .. "] mission armed: " .. missionName .. " (" .. #engine.Triggers() .. " trigger(s))")
return true
end
diff --git a/modules/missions/rml_widgets/mission_editor.lua b/modules/missions/rml_widgets/mission_editor.lua
new file mode 100644
index 00000000000..97e47ac5d20
--- /dev/null
+++ b/modules/missions/rml_widgets/mission_editor.lua
@@ -0,0 +1,817 @@
+-- Authoring tool: don't parse the document for players who cannot open it.
+if not RmlUi or not Spring.Utilities.IsDevMode() then
+ return
+end
+
+local widget = widget ---@type Widget
+
+function widget:GetInfo()
+ return {
+ name = "Mission Editor",
+ desc = "Terminal for the bar-mission-kit view artifact: renders server-built markup, posts edit intents; the mission's .lua files stay the source of truth",
+ author = "Beyond All Reason",
+ date = "July 2026",
+ license = "GNU GPL, v2 or later",
+ layer = 0,
+ enabled = true,
+ handler = true,
+ }
+end
+
+-- Blind terminal: bar-mission-kit serve renders
+-- the whole view (mission_view.json); this widget injects it via inner_rml
+-- and routes events back as edit intents keyed by data-* attributes. No
+-- mission grammar lives here. Game-side production (domains, live sampling,
+-- hot-reload) is mission_bridge.lua — this file only displays and posts.
+
+local VIEW_PATH = "modules/missions/.editor/mission_view.json"
+local STATUS_PATH = "modules/missions/.editor/status.json"
+local STATE_PATH = "modules/missions/.editor/state.json"
+-- Written via io (relative to the engine WRITE dir); serve watches the same
+-- dir — just bar::mission-serve points there.
+local OPEN_REQUEST_PATH = "modules/missions/.editor/open_request.json"
+local EDITS_DIR = "modules/missions/.editor/edits"
+local RML_PATH = "modules/missions/rml_widgets/mission_editor.rml"
+local POLL_SECONDS = 0.5
+local LIVE_PATCH_SECONDS = 1.0
+local EDIT_DEBOUNCE_SECONDS = 0.8
+
+-- Engine-provided in the widget env (system.lua whitelists it). Do NOT
+-- VFS.Include json.lua: it reads `local base = _G`, nil in widget sandboxes.
+local Json = Json
+if not Json then
+ return
+end
+
+local document
+local visible = false
+local focusedInput = nil ---@type RmlUi.Element|nil the input holding keyboard focus, if any
+local viewScale = 1 -- set by applyViewLayout; sizes track the scaled font
+
+-- Inputs track the view window, not their content — width that changes
+-- under the caret while typing is gross. The rcss widths are authored for
+-- 1080p and RmlUi has no vw units, so the panel's own scale factor is
+-- applied inline, per class, once at wire time.
+local function sizeInput(input)
+ local base = 200
+ if input:IsClassSet("me-input-num") then
+ base = 56
+ elseif input:IsClassSet("me-input-obj") then
+ base = 260
+ end
+ input.style.width = math.floor(base * viewScale) .. "px"
+end
+local lobbyHidden = false -- true while the lobby/menu overlay is up (LobbyOverlayActive)
+local mode = "form" ---@type "form"|"text"
+local lastGeneration = nil
+local lastArmed = nil
+local pollAccumulator = 0
+local liveAccumulator = 0
+local currentView = nil
+local freshView = nil -- validated by pollArtifact, handed to the next loadView
+local pendingEdits = {}
+-- os.clock() keeps rising across widget reloads, so intent names stay unique
+-- and ordered even pre-game, where GetGameFrame() and a fresh counter are both 0.
+local editSequence = math.floor(os.clock() * 1000)
+local liveSpans = nil
+local collapsedSections = {}
+local pickerBypassed = false
+local applyViewLayout, renderCurrent
+
+local function escape(text)
+ return (tostring(text):gsub("&", "&"):gsub('"', """):gsub("<", "<"):gsub(">", ">"))
+end
+
+--- Glyphs the served terminal uses that no font RmlUi loads can draw (Exo 2
+--- and Poppins both stop short of U+2713), and the nearest one they can. A
+--- missing glyph is a tofu box, so this is the difference between a check
+--- mark and a blank square.
+local GLYPHS = {
+ ["\226\156\147"] = "\226\136\154", -- check -> radical
+ ["\226\150\184"] = "\194\187", -- right-pointing triangle -> guillemet
+}
+
+---@param markup string
+---@return string
+local function drawable(markup)
+ for from, to in pairs(GLYPHS) do
+ markup = markup:gsub(from, to)
+ end
+ return markup
+end
+
+--------------------------------------------------------------------------------
+-- The intent channel: filesystem out, regeneration back.
+--------------------------------------------------------------------------------
+
+---Mode switch to code: ask the serve process to open the file in the IDE.
+local function requestOpenInEditor(filePath, line)
+ Spring.CreateDir("modules/missions/.editor")
+ local handle = io.open(OPEN_REQUEST_PATH, "w")
+ if handle == nil then
+ Spring.Echo("[mission_editor] cannot write " .. OPEN_REQUEST_PATH)
+ return
+ end
+ handle:write(Json.encode({ file = filePath, line = line or 1 }))
+ handle:close()
+end
+
+---@param edit { file: string, start: number, finish: number, hash: string, quote: boolean, value: string }
+local function writeEditIntent(edit)
+ Spring.CreateDir(EDITS_DIR)
+ editSequence = editSequence + 1
+ -- Zero-padded so a plain lexicographic drain is submission order too.
+ local path = string.format("%s/%012d.json", EDITS_DIR, editSequence)
+ local handle = io.open(path, "w")
+ if not handle then
+ Spring.Echo("[mission_editor] cannot write edit intent " .. path)
+ return
+ end
+ local newText = edit.value
+ if edit.quote then
+ newText = '"' .. newText .. '"'
+ end
+ handle:write(Json.encode({
+ file = edit.file,
+ start = edit.start,
+ ["end"] = edit.finish,
+ new_text = newText,
+ base_hash = edit.hash,
+ }))
+ handle:close()
+end
+
+---Flush debounced field edits; the last value within a field's window wins.
+local function flushPendingEdits()
+ local now = os.clock()
+ for key, edit in pairs(pendingEdits) do
+ if now >= edit.deadline then
+ pendingEdits[key] = nil
+ writeEditIntent(edit)
+ end
+ end
+end
+
+local function queueFieldEdit(element, value, deadline)
+ local start = tonumber(element:GetAttribute("data-start"))
+ if start == nil then
+ return
+ end
+ pendingEdits[element:GetAttribute("data-file") .. ":" .. start] = {
+ file = element:GetAttribute("data-file"),
+ start = start,
+ finish = tonumber(element:GetAttribute("data-end")),
+ hash = element:GetAttribute("data-hash"),
+ quote = element:GetAttribute("data-quote") == "1",
+ value = value,
+ deadline = deadline,
+ }
+end
+
+--------------------------------------------------------------------------------
+-- Live chips: patch [data-live] spans from state.json — the same artifact
+-- every other terminal reads; the bridge produces it.
+--------------------------------------------------------------------------------
+
+local function collectLiveSpans()
+ liveSpans = {}
+ local content = document and document:GetElementById("me-content")
+ if not content then
+ return
+ end
+ local spans = content:GetElementsByTagName("span")
+ for i = 1, #spans do
+ local key = spans[i]:GetAttribute("data-live")
+ if key then
+ liveSpans[#liveSpans + 1] = { element = spans[i], key = key }
+ end
+ end
+end
+
+local function applyLive()
+ if not (visible and mode == "form") then
+ return
+ end
+ local text = VFS.LoadFile(STATE_PATH, VFS.RAW_FIRST)
+ if not text then
+ return
+ end
+ local ok, state = pcall(Json.decode, text)
+ if not ok or type(state) ~= "table" or type(state.values) ~= "table" then
+ return
+ end
+ if not liveSpans then
+ collectLiveSpans()
+ end
+ for _, entry in ipairs(liveSpans) do
+ local value = state.values[entry.key]
+ if value then
+ entry.element.inner_rml = drawable(escape(value.text))
+ entry.element:SetClass("done", value.state == "done")
+ end
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Modal: rows come as structured data; kind picks the insert position from
+-- the opening button's attributes.
+--------------------------------------------------------------------------------
+
+local function closeModal()
+ local modal = document and document:GetElementById("me-modal")
+ if modal then
+ modal.style.display = "none"
+ end
+end
+
+local function applyModalRow(row, button)
+ local start, finish
+ if row.kind == "swap" then
+ start = tonumber(button:GetAttribute("data-swap-start"))
+ finish = tonumber(button:GetAttribute("data-swap-end"))
+ elseif row.kind == "trigger" then
+ start = tonumber(button:GetAttribute("data-insert"))
+ finish = start
+ elseif row.kind == "andwhen" then
+ start = tonumber(button:GetAttribute("data-insert-cond"))
+ finish = start
+ else -- effect
+ start = tonumber(button:GetAttribute("data-insert-effect"))
+ finish = start
+ end
+ if not (start and finish) then
+ return
+ end
+ writeEditIntent({
+ file = button:GetAttribute("data-file"),
+ start = start,
+ finish = finish,
+ hash = button:GetAttribute("data-hash"),
+ quote = false,
+ value = row.new_text,
+ })
+ closeModal()
+end
+
+local function openModal(key, button)
+ local payload = currentView and currentView.modals and currentView.modals[key]
+ local modal = document:GetElementById("me-modal")
+ local titleEl = document:GetElementById("me-modal-title")
+ local rowsEl = document:GetElementById("me-modal-rows")
+ if not (payload and modal and titleEl and rowsEl) then
+ return
+ end
+ titleEl.inner_rml = escape(payload.title)
+ local out = {}
+ for i, row in ipairs(payload.rows) do
+ if row.kind == "group" then
+ out[#out + 1] = '
' .. escape(row.label) .. "
"
+ else
+ out[#out + 1] = '
' .. escape(row.label) .. "
"
+ end
+ end
+ rowsEl.inner_rml = table.concat(out)
+ local divs = rowsEl:GetElementsByTagName("div")
+ for i = 1, #divs do
+ local rowEl = divs[i]
+ local index = tonumber(rowEl:GetAttribute("data-row"))
+ if index then
+ rowEl:AddEventListener("click", function()
+ applyModalRow(payload.rows[index], button)
+ end)
+ end
+ end
+ modal.style.display = "block"
+end
+
+--------------------------------------------------------------------------------
+-- Generic wiring: every control routes by data-* attributes.
+--------------------------------------------------------------------------------
+
+local function wireOpenTarget(element)
+ local openFile = element:GetAttribute("data-open-file")
+ if openFile then
+ element:AddEventListener("click", function()
+ requestOpenInEditor(openFile, tonumber(element:GetAttribute("data-open-line")) or 1)
+ end)
+ end
+end
+
+--- Sections the served terminal renders that the in-game panel does not: the
+--- graph is a layout the RmlUi backend cannot draw, and its flat fallback is
+--- not worth the room on a game HUD.
+local HIDDEN_SECTIONS = { graph = true }
+
+local function attachHandlers()
+ local content = document:GetElementById("me-content")
+ if not content then
+ return
+ end
+ local sectionByKey = {}
+ -- The breadcrumb's root toggles one of these: missions to switch mission,
+ -- modules to pick another module's surface.
+ local listByNav = {}
+ local divs = content:GetElementsByTagName("div")
+ for i = 1, #divs do
+ local div = divs[i]
+ if div:GetAttribute("data-mission-list") then
+ listByNav.missions = div
+ elseif div:GetAttribute("data-module-list") then
+ listByNav.modules = div
+ end
+ local sectionKey = div:GetAttribute("data-section")
+ if sectionKey then
+ sectionByKey[sectionKey] = div
+ if HIDDEN_SECTIONS[sectionKey] then
+ div:SetClass("me-hidden", true)
+ elseif collapsedSections[sectionKey] ~= nil then
+ div:SetClass("collapsed", collapsedSections[sectionKey])
+ end
+ end
+ wireOpenTarget(div)
+ end
+ local spans = content:GetElementsByTagName("span")
+ for i = 1, #spans do
+ wireOpenTarget(spans[i])
+ end
+ local inputs = content:GetElementsByTagName("input")
+ for i = 1, #inputs do
+ local input = inputs[i]
+ -- Form controls set tab-index: auto INLINE in their C++ constructor
+ -- (ElementFormControl.cpp), which no stylesheet can override — the
+ -- rcss rules never stood a chance. Inline beats inline: TAB belongs
+ -- to the game.
+ input.style["tab-index"] = "none"
+ sizeInput(input)
+ -- Printable keys only reach RmlUi inputs while SDL text-input mode is
+ -- on (gui_chat does the same dance). The focused input is remembered
+ -- so a click on the game world can blur it: RmlUi keeps focus when
+ -- the pointer leaves the panel, and a field nobody can see holding
+ -- focus eats gameplay keystrokes — one stray control-group press
+ -- rewrote a trigger's unit name.
+ input:AddEventListener("focus", function()
+ focusedInput = input
+ Spring.SDLStartTextInput()
+ end)
+ input:AddEventListener("blur", function()
+ if focusedInput == input then
+ focusedInput = nil
+ end
+ Spring.SDLStopTextInput()
+ end)
+ input:AddEventListener("change", function()
+ queueFieldEdit(input, tostring(input:GetAttribute("value") or ""), os.clock() + EDIT_DEBOUNCE_SECONDS)
+ end)
+ end
+ local buttons = content:GetElementsByTagName("button")
+ for i = 1, #buttons do
+ local button = buttons[i]
+ button.style["tab-index"] = "none"
+ local pool = button:GetAttribute("data-pool")
+ local add = button:GetAttribute("data-add")
+ -- A row in the crumb's list. In the browser this re-scopes the served
+ -- view; in game the equivalent is arming that mission, which the panel
+ -- then follows.
+ local pick = button:GetAttribute("data-select-mission")
+ if pick then
+ button:AddEventListener("click", function()
+ Spring.SendCommands("luarules mission load " .. pick)
+ end)
+ end
+ local navKey = button:GetAttribute("data-nav")
+ if navKey then
+ -- Same behaviour as the browser terminal: the crumb root shows or
+ -- hides the list of things you can navigate to.
+ button:AddEventListener("click", function()
+ local list = listByNav[navKey]
+ if list then
+ list:SetClass("collapsed", not list:IsClassSet("collapsed"))
+ end
+ end)
+ end
+ local toggleKey = button:GetAttribute("data-toggle")
+ if toggleKey then
+ button:AddEventListener("click", function()
+ local section = sectionByKey[toggleKey]
+ if section then
+ local nowCollapsed = not section:IsClassSet("collapsed")
+ section:SetClass("collapsed", nowCollapsed)
+ collapsedSections[toggleKey] = nowCollapsed
+ end
+ end)
+ elseif pool then
+ button:AddEventListener("click", function()
+ openModal("swap_" .. pool, button)
+ end)
+ elseif add then
+ button:AddEventListener("click", function()
+ -- Same mapping as the browser terminal; anything unmapped is a
+ -- per-trigger add (data-add="step").
+ local byAdd = { statement = "add_statement", spawn = "add_spawn", declaration = "add_declaration" }
+ openModal(byAdd[add] or "add_step", button)
+ end)
+ elseif button:GetAttribute("data-op") == "remove" then
+ button:AddEventListener("click", function()
+ writeEditIntent({
+ file = button:GetAttribute("data-file"),
+ start = tonumber(button:GetAttribute("data-remove-start")),
+ finish = tonumber(button:GetAttribute("data-remove-end")),
+ hash = button:GetAttribute("data-hash"),
+ quote = false,
+ value = "",
+ })
+ end)
+ end
+ end
+ local selects = content:GetElementsByTagName("select")
+ for i = 1, #selects do
+ local select = selects[i]
+ select.style["tab-index"] = "none"
+ -- The dropdown's own option-click listeners don't fire for
+ -- inner_rml-built selects in this build; arm the options ourselves.
+ local control = RmlUi.Element.As.ElementFormControlSelect(select)
+ if control then
+ local options = select:GetElementsByTagName("option")
+ for optionIndex = 1, #options do
+ local option = options[optionIndex]
+ option:AddEventListener("click", function()
+ control.selection = optionIndex - 1
+ select:Blur()
+ end)
+ end
+ end
+ select:AddEventListener("change", function()
+ queueFieldEdit(select, tostring(select:GetAttribute("value") or ""), 0)
+ end)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Panel state
+--------------------------------------------------------------------------------
+
+local function loadView()
+ if freshView then
+ local view = freshView
+ freshView = nil
+ return view
+ end
+ local text = VFS.LoadFile(VIEW_PATH, VFS.RAW_FIRST)
+ if not text then
+ return nil, "no view artifact at " .. VIEW_PATH .. " — run: just bar::mission-serve"
+ end
+ local ok, view = pcall(Json.decode, text)
+ if not ok or type(view) ~= "table" then
+ return nil, "cannot decode " .. VIEW_PATH
+ end
+ return view
+end
+
+local function serveStatusLine()
+ local text = VFS.LoadFile(STATUS_PATH, VFS.RAW_FIRST)
+ if not text then
+ return nil
+ end
+ local ok, status = pcall(Json.decode, text)
+ if not ok or type(status) ~= "table" or status.ok then
+ return nil
+ end
+ return '
' .. escape(status.message) .. "
"
+end
+
+local function refresh()
+ if not document then
+ return
+ end
+ local view, err = loadView()
+ currentView = view or currentView
+ local content = document:GetElementById("me-content")
+ if content then
+ content.inner_rml = drawable((serveStatusLine() or "") .. (view and view.form or ('
' .. (err or "?") .. "
")))
+ attachHandlers()
+ liveSpans = nil
+ end
+end
+
+---Text-mode billboard: BIG header + serve status + the read-only view.
+local function fillBillboard()
+ local strip = document:GetElementById("me-textmode")
+ if not strip then
+ return
+ end
+ local view = loadView()
+ currentView = view or currentView
+ strip.inner_rml = '
EDITING IN VS CODE
' .. (serveStatusLine() or '
The form follows your saves.
') .. '
' .. (view and view.billboard or "") .. "
"
+end
+
+---Premature entry (/mission editor with nothing armed) drops you at the
+---mission list; clicking starts one through the loader's own chat action.
+---The runnable list is game domain data: only the VFS knows what shipped.
+local function listMissions()
+ local missions = {}
+ for _, dir in ipairs(VFS.SubDirs("modules/missions/") or {}) do
+ local name = dir:match("([^/]+)/?$")
+ if name and #VFS.DirList(dir .. "triggers/", "*.lua") > 0 then
+ missions[#missions + 1] = name
+ end
+ end
+ table.sort(missions)
+ return missions
+end
+
+local function renderPicker()
+ local content = document:GetElementById("me-content")
+ if not content then
+ return
+ end
+ local out = { '
START A MISSION
' }
+ for _, name in ipairs(listMissions()) do
+ out[#out + 1] = '
' .. escape(name) .. "
"
+ end
+ out[#out + 1] = ''
+ content.inner_rml = table.concat(out)
+ local divs = content:GetElementsByTagName("div")
+ for i = 1, #divs do
+ local row = divs[i]
+ local mission = row:GetAttribute("data-mission")
+ if mission then
+ row:AddEventListener("click", function()
+ Spring.SendCommands("luarules mission load " .. mission)
+ end)
+ end
+ end
+ local buttons = content:GetElementsByTagName("button")
+ for i = 1, #buttons do
+ if buttons[i]:GetAttribute("data-bypass") then
+ buttons[i]:AddEventListener("click", function()
+ pickerBypassed = true
+ refresh()
+ end)
+ end
+ end
+end
+
+renderCurrent = function()
+ -- A rebuild destroys whatever field held keyboard focus WITHOUT firing
+ -- its blur, which would leave SDL text-input mode stuck on — and the
+ -- engine then feeds keys to text entry instead of keybinds, which is how
+ -- TAB stopped selecting the commander and a stray control-group press
+ -- rewrote a trigger file. Hand the keyboard back before rebuilding.
+ if focusedInput ~= nil then
+ pcall(function()
+ focusedInput:Blur()
+ end)
+ focusedInput = nil
+ end
+ Spring.SDLStopTextInput()
+ if mode == "form" then
+ local armed = Spring.GetGameRulesParam("mission_active") == 1
+ if armed or pickerBypassed then
+ refresh()
+ else
+ renderPicker()
+ end
+ else
+ fillBillboard()
+ end
+end
+
+---Poll the view artifact; on a new generation, re-render. (Hot-reloading the
+---running mission is the bridge's job.)
+local function pollArtifact(dt)
+ pollAccumulator = pollAccumulator + dt
+ if pollAccumulator < POLL_SECONDS then
+ return
+ end
+ pollAccumulator = 0
+ local text = VFS.LoadFile(VIEW_PATH, VFS.RAW_FIRST)
+ if not text then
+ return
+ end
+ local generation = text:match('"generation"%s*:%s*(%d+)')
+ if generation == lastGeneration then
+ return
+ end
+ -- serve writes the artifact non-atomically. A torn read must not consume the
+ -- generation, or this panel never re-renders it.
+ local ok, view = pcall(Json.decode, text)
+ if not ok or type(view) ~= "table" then
+ return
+ end
+ lastGeneration = generation
+ freshView = view
+ if visible then
+ renderCurrent()
+ end
+end
+
+function widget:Update(dt)
+ pollArtifact(dt)
+ flushPendingEdits()
+ -- Arming flips the panel from the picker to the form (and back).
+ local armed = Spring.GetGameRulesParam("mission_active") == 1
+ if armed ~= lastArmed then
+ lastArmed = armed
+ if visible then
+ renderCurrent()
+ end
+ end
+ liveAccumulator = liveAccumulator + dt
+ if liveAccumulator >= LIVE_PATCH_SECONDS then
+ liveAccumulator = 0
+ applyLive()
+ end
+end
+
+---vw units resolve to 0 in this RmlUi build: place the panel from real view
+---geometry, scale the base font with the view height.
+applyViewLayout = function()
+ local root = document:GetElementById("me-root")
+ if not root then
+ return
+ end
+ local vsx, vsy = Spring.GetViewGeometry()
+ local scale = math.max(0.9, math.min(1.8, vsy / 1080))
+ viewScale = scale
+ local width = math.floor(580 * scale)
+ root.style.left = tostring(math.max(0, vsx - width - 40)) .. "px"
+ root.style.width = tostring(width) .. "px"
+ root.style["font-size"] = tostring(math.floor(17 * scale)) .. "px"
+ -- The scroll window tracks the screen, not a hardcoded height.
+ local content = document:GetElementById("me-content")
+ if content then
+ content.style["max-height"] = tostring(math.max(420, vsy - 330)) .. "px"
+ end
+end
+
+function widget:ViewResize()
+ if document and visible then
+ applyViewLayout()
+ end
+end
+
+---Form mode shows the cards; text mode collapses to the billboard while the
+---real IDE owns the file. Same document, no close on mode switch.
+local function setMode(newMode)
+ mode = newMode
+ local content = document:GetElementById("me-content")
+ local strip = document:GetElementById("me-textmode")
+ local editButton = document:GetElementById("me-edit")
+ local isForm = mode == "form"
+ if content then
+ content.style.display = isForm and "block" or "none"
+ end
+ if strip then
+ strip.style.display = isForm and "none" or "block"
+ if not isForm then
+ fillBillboard()
+ end
+ end
+ if editButton then
+ editButton.inner_rml = isForm and "Edit" or "Form"
+ end
+end
+
+local function setVisible(on)
+ visible = on
+ if not document then
+ return
+ end
+ if on then
+ renderCurrent()
+ applyViewLayout()
+ if not lobbyHidden then
+ -- Never take keyboard focus on show: a focused document eats TAB
+ -- (and friends) that belong to the game. Inputs still focus on click.
+ document:Show(RmlUi.RmlModalFlag.None, RmlUi.RmlFocusFlag.None)
+ end
+ else
+ document:Hide()
+ end
+end
+
+-- Hide while the lobby/menu overlay is up so the panel never draws over
+-- Chobby (LobbyOverlayActive broadcast from barwidgets.lua, same handling as
+-- gui_feature_placer).
+function widget:RecvLuaMsg(message)
+ if not document then
+ return
+ end
+ if message:sub(1, 19) == "LobbyOverlayActive0" then
+ lobbyHidden = false
+ if visible then
+ document:Show(RmlUi.RmlModalFlag.None, RmlUi.RmlFocusFlag.None)
+ end
+ elseif message:sub(1, 19) == "LobbyOverlayActive1" then
+ lobbyHidden = true
+ document:Hide()
+ end
+end
+
+function widget:Initialize()
+ local context = RmlUi.GetContext("shared")
+ if not context then
+ return false
+ end
+ document = context:LoadDocument(RML_PATH)
+ if not document then
+ return false
+ end
+
+ -- The document's STATIC form controls (terminal input, header buttons)
+ -- carry the same constructor-inline tab-index: auto; injected content is
+ -- handled where it is wired, this covers what the .rml ships.
+ for _, tag in ipairs({ "input", "button", "select", "textarea" }) do
+ local els = document:GetElementsByTagName(tag)
+ for i = 1, #els do
+ els[i].style["tab-index"] = "none"
+ end
+ end
+
+ local editButton = document:GetElementById("me-edit")
+ if editButton then
+ editButton:AddEventListener("click", function()
+ if mode == "form" then
+ local first = currentView and currentView.first_file
+ if first then
+ requestOpenInEditor(first, 1)
+ end
+ setMode("text")
+ else
+ setMode("form")
+ refresh()
+ end
+ end)
+ end
+ local refreshButton = document:GetElementById("me-refresh")
+ if refreshButton then
+ refreshButton:AddEventListener("click", function()
+ renderCurrent()
+ end)
+ end
+ local reloadButton = document:GetElementById("me-reload")
+ if reloadButton then
+ reloadButton:AddEventListener("click", function()
+ Spring.SendCommands("luarules mission reload")
+ renderCurrent()
+ end)
+ end
+ local modalCancel = document:GetElementById("me-modal-cancel")
+ if modalCancel then
+ modalCancel:AddEventListener("click", function()
+ closeModal()
+ end)
+ end
+ local closeButton = document:GetElementById("me-close")
+ if closeButton then
+ closeButton:AddEventListener("click", function()
+ setVisible(false)
+ end)
+ end
+
+ -- `/mission ...` is one verb to a player, but arming is synced and the
+ -- panel is not, so load and reload are forwarded to the module's actions
+ -- rather than done here. editor is the only subcommand this side owns.
+ widgetHandler.actionHandler:AddAction(self, "mission", function(_, line)
+ local subcommand, rest = (line or ""):match("^%s*(%S*)%s*(.*)$")
+ if subcommand == "editor" then
+ setVisible(not visible)
+ elseif subcommand == "load" and rest ~= "" then
+ Spring.SendCommands("luarules mission load " .. rest)
+ elseif subcommand == "reload" then
+ Spring.SendCommands("luarules mission reload")
+ else
+ Spring.Echo("[mission_editor] usage: /mission editor | /mission load | /mission reload")
+ end
+ return true
+ end, nil, "t")
+
+ document:Hide()
+ Spring.Echo("[mission_editor] terminal build 4")
+ return true
+end
+
+--- A press that reaches the widget handler landed on the game world — RmlUi
+--- consumes clicks over its own documents — so whatever field still holds
+--- keyboard focus gives it back. Never consumes the click.
+function widget:MousePress()
+ if focusedInput ~= nil then
+ pcall(function()
+ focusedInput:Blur()
+ end)
+ focusedInput = nil
+ Spring.SDLStopTextInput()
+ end
+ return false
+end
+
+function widget:Shutdown()
+ -- Never leave the engine in text-entry mode: a reload with a focused
+ -- field would otherwise eat every keybind until something else stops it.
+ Spring.SDLStopTextInput()
+ widgetHandler.actionHandler:RemoveAction(self, "mission", "t")
+ if document then
+ document:Close()
+ document = nil
+ end
+end
diff --git a/modules/missions/rml_widgets/mission_editor.rcss b/modules/missions/rml_widgets/mission_editor.rcss
new file mode 100644
index 00000000000..6332960b377
--- /dev/null
+++ b/modules/missions/rml_widgets/mission_editor.rcss
@@ -0,0 +1,793 @@
+/* Mission Editor theme; markup is server-rendered (bar-mission-kit), this is
+ the entire game-side look. One bad declaration can take its whole block down, so risky properties (box-shadow) sit in their own blocks. */
+
+.me-root {
+ position: absolute;
+ top: 120px;
+ left: 40px;
+ width: 500px;
+ z-index: 10000;
+ decorator: vertical-gradient( #111823f6 #0a0e14fa );
+ border: 1px #223040;
+ border-radius: 10px;
+ padding: 14px 16px;
+ font-family: Exo 2;
+ font-size: 16px;
+ color: #d5dde7;
+}
+
+.me-root {
+ box-shadow: 0dp 12dp 34dp 0dp #000000a8, inset 0dp 1dp 0dp 0dp #ffffff12;
+}
+
+.me-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+ padding-bottom: 10px;
+ border-bottom: 1px #1d2938;
+}
+
+.me-title {
+ flex: 1;
+ font-size: 1.1em;
+ color: #ffffff;
+ white-space: nowrap;
+}
+
+/* Tab belongs to the game, not to this panel — but NOT via stylesheet.
+ *
+ * These rules are provably dead: ElementFormControl's C++ constructor sets
+ * tab-index: auto as an INLINE property (ElementFormControl.cpp), and inline
+ * outranks every stylesheet rule. The real fix lives in mission_editor.lua,
+ * which sets element.style["tab-index"] = "none" on every form control it
+ * wires — inline beats inline. Kept here as documentation so nobody
+ * reintroduces a stylesheet rule expecting it to work. */
+
+.me-button {
+ display: inline-block;
+ margin-left: 6px;
+ padding: 3px 11px;
+ background-color: #ffffff09;
+ border: 1px #2c3b4d;
+ border-radius: 5px;
+ color: #9fb3c8;
+ font-size: 0.8em;
+ cursor: pointer;
+}
+
+.me-button:hover {
+ background-color: #22303f;
+ border: 1px #4c6784;
+ color: #ffffff;
+}
+
+.me-content {
+ display: block;
+ overflow-y: auto;
+ max-height: 560px;
+}
+
+.me-file {
+ display: block;
+ margin: 8px 0 4px 0;
+ color: #55677a;
+ font-size: 0.7em;
+}
+
+.me-group {
+ display: block;
+ margin: 12px 0 4px 0;
+ color: #e8c884;
+ font-size: 0.8em;
+}
+
+/* One flat card: triggers are left-ruled groups, not nested boxes. */
+.me-card {
+ display: block;
+ border-left: 2px #2c3b4d;
+ margin: 6px 0;
+ padding: 1px 0 1px 12px;
+}
+
+.me-section {
+ display: block;
+ border-top: 1px #1d2938;
+ margin-top: 4px;
+}
+
+.me-section-head {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ background-color: transparent;
+ border: 0;
+ color: #8fa3b8;
+ font-size: 0.72em;
+ padding: 10px 2px 8px 2px;
+ cursor: pointer;
+}
+
+.me-section-head:hover {
+ color: #cfe4ff;
+}
+
+/* No auto margins in this build: the title flexes to push the hint right.
+ The caret glyph is web-only (Exo 2 lacks it); hidden here. */
+.me-section-title {
+ flex: 1;
+}
+
+.me-section-hint {
+ color: #4c5d70;
+}
+
+.me-caret {
+ display: none;
+}
+
+.me-section.collapsed .me-section-body {
+ display: none;
+}
+
+.me-section-body {
+ display: block;
+}
+
+.me-noun-group {
+ display: block;
+ color: #55677a;
+ font-size: 0.65em;
+ margin: 10px 0 3px 0;
+}
+
+.me-noun {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 3px 4px;
+ border-radius: 4px;
+}
+
+.me-noun-name {
+ color: #c6d2df;
+}
+
+.me-noun-open {
+ cursor: pointer;
+}
+
+.me-noun-open:hover {
+ background-color: #1a2634;
+}
+
+
+/* Chrome: ghost ordinal (open-in-editor handle) with add/remove floated right. */
+.me-card-head {
+ display: block;
+ position: relative;
+ margin-bottom: 2px;
+ padding-right: 66px;
+}
+
+.me-card-head .me-button {
+ padding: 0px 7px;
+ font-size: 0.7em;
+}
+
+.me-card-x {
+ position: absolute;
+ right: 0px;
+ top: 0px;
+ margin-left: 0;
+}
+
+.me-card-add {
+ position: absolute;
+ right: 30px;
+ top: 0px;
+ margin-left: 0;
+}
+
+.me-card-title {
+ display: inline-block;
+ color: #4c5d70;
+ font-size: 0.68em;
+}
+
+/* The row is the target, not the text: a tint reads as clickable without
+ claiming the words are a link. */
+.me-jump {
+ cursor: pointer;
+ border-radius: 4px;
+}
+
+.me-jump:hover {
+ background-color: #16202c;
+}
+
+.me-step {
+ display: flex;
+ align-items: center;
+ margin: 3px 0;
+}
+
+.me-step-live {
+ margin-left: 6px;
+}
+
+.me-step-tools {
+ margin-left: 6px;
+}
+
+/* Pills size to their verb (GROUPED is wider than WHEN); a min keeps the
+ short ones from shrinking into dots. */
+.me-step-verb {
+ display: inline-block;
+ min-width: 42px;
+ margin-right: 10px;
+ padding: 2px 8px;
+ text-align: center;
+ font-size: 0.62em;
+ border-radius: 9px;
+}
+
+.me-verb-cond {
+ background-color: #16283c;
+ border: 1px #33517a;
+ color: #8fbcff;
+}
+
+.me-verb-effect {
+ background-color: #33230e;
+ border: 1px #6b4a1e;
+ color: #ffb45e;
+}
+
+.me-verb-mod {
+ background-color: #1c242e;
+ border: 1px #3a4754;
+ color: #97a5b4;
+}
+
+.me-step-body {
+ flex: 1;
+ color: #dbe4ee;
+}
+
+.me-x-btn {
+ margin-left: 4px;
+ padding: 1px 6px;
+ font-size: 0.7em;
+}
+
+.me-x {
+ color: #d98a92;
+ border: 1px #4a2c31;
+ background-color: #d94a5410;
+}
+
+.me-x:hover {
+ background-color: #58242b;
+ border: 1px #8a4a52;
+ color: #ffd3d7;
+}
+
+.me-swap-btn {
+ color: #8fbcff;
+ border: 1px #2c3b4d;
+}
+
+.me-add-row {
+ display: block;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px #1c2836;
+}
+
+.me-add-btn {
+ margin-left: 0;
+ background-color: #4f8ce00e;
+ border: 1px #2c405a;
+ color: #7fa9d8;
+}
+
+.me-add-btn:hover {
+ background-color: #24384f;
+ border: 1px #4f7099;
+ color: #cfe4ff;
+}
+
+.me-input {
+ display: inline-block;
+ width: 180px;
+ height: 1.6em;
+ vertical-align: middle;
+ padding: 1px 6px;
+ background-color: #0a0f16;
+ border: 1px #263544;
+ border-radius: 5px;
+ color: #ffd08a;
+ font-size: 0.95em;
+}
+
+.me-input:focus {
+ border: 1px #6ea8e8;
+ background-color: #0c1420;
+}
+
+.me-input-num {
+ width: 52px;
+}
+
+.me-input-obj {
+ width: 230px;
+}
+
+.me-select {
+ display: inline-block;
+ width: 200px;
+ height: 1.6em;
+ vertical-align: middle;
+ padding: 0;
+ background-color: #0a0f16;
+ border: 1px #263544;
+ border-radius: 5px;
+ color: #9fd3a4;
+ font-size: 0.95em;
+}
+
+.me-select:hover {
+ border: 1px #4c6784;
+}
+
+.me-select-unit {
+ width: 320px;
+}
+
+.me-select selectvalue {
+ padding: 2px 20px 2px 7px;
+ white-space: nowrap;
+ overflow-x: hidden;
+ overflow-y: hidden;
+}
+
+.me-select selectarrow {
+ width: 16px;
+ background-color: #1d2a39;
+ border-left: 1px #263544;
+ border-radius: 0 5px 5px 0;
+}
+
+.me-select selectbox {
+ decorator: vertical-gradient( #1a2431f8 #10161dfa );
+ border: 1px #34455a;
+ border-radius: 6px;
+ padding: 3px;
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+.me-select selectbox {
+ box-shadow: 0dp 8dp 22dp 0dp #00000090;
+}
+
+.me-select selectbox option {
+ display: block;
+ width: 100%;
+ padding: 3px 8px;
+ border-radius: 3px;
+ color: #c6d2df;
+ white-space: nowrap;
+ overflow-x: hidden;
+}
+
+.me-select selectbox option:hover {
+ background-color: #2c4158;
+ color: #ffffff;
+}
+
+scrollbarvertical {
+ width: 10px;
+}
+
+scrollbarvertical slidertrack {
+ background-color: #0c121a;
+}
+
+scrollbarvertical sliderbar {
+ background-color: #33435a;
+ border-radius: 5px;
+ min-height: 24px;
+}
+
+scrollbarvertical sliderbar:hover {
+ background-color: #4c6784;
+}
+
+scrollbarvertical sliderarrowdec,
+scrollbarvertical sliderarrowinc {
+ width: 0px;
+ height: 0px;
+}
+
+.me-modal {
+ display: none;
+ position: absolute;
+ left: 0px;
+ top: 0px;
+ width: 100%;
+ height: 100%;
+ z-index: 300;
+ background-color: #05080cd0;
+}
+
+.me-modal-box {
+ position: absolute;
+ left: 50%;
+ top: 90px;
+ margin-left: -180px;
+ width: 360px;
+ decorator: vertical-gradient( #1a2431f8 #10161dfa );
+ border: 1px #34455a;
+ border-radius: 8px;
+ padding: 12px 14px;
+}
+
+.me-modal-box {
+ box-shadow: 0dp 14dp 40dp 0dp #000000b0;
+}
+
+.me-modal-title {
+ display: block;
+ color: #ffffff;
+ font-size: 1.0em;
+ margin-bottom: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px #1d2938;
+}
+
+.me-modal-group {
+ display: block;
+ margin: 10px 0 3px 0;
+ color: #e8c884;
+ font-size: 0.65em;
+}
+
+.me-modal-row {
+ display: block;
+ padding: 6px 9px;
+ color: #c6d2df;
+ border-radius: 5px;
+ cursor: pointer;
+}
+
+.me-modal-row:hover {
+ background-color: #2c4158;
+ color: #ffffff;
+}
+
+.me-modal-cancel {
+ margin-top: 10px;
+ margin-left: 0;
+}
+
+.me-textmode {
+ display: none;
+ margin: 4px 0;
+}
+
+.me-bighead {
+ display: block;
+ font-size: 1.7em;
+ color: #ffffff;
+ margin: 4px 0 6px 0;
+}
+
+.me-followline {
+ display: block;
+ color: #8fa3b8;
+ margin-bottom: 6px;
+}
+
+.me-billboard {
+ display: block;
+ font-size: 1.25em;
+}
+
+.me-verb {
+ color: #82aaff;
+}
+
+.me-lit {
+ color: #f6c177;
+}
+
+.me-ref {
+ color: #9fd3a4;
+}
+
+.me-live {
+ display: inline-block;
+ margin-left: 8px;
+ padding: 1px 8px;
+ border-radius: 9px;
+ font-size: 0.7em;
+ background-color: #16283c;
+ border: 1px #33517a;
+ color: #8fbcff;
+}
+
+.me-live.done {
+ background-color: #12301c;
+ border: 1px #2e6b40;
+ color: #7fd79a;
+}
+
+.me-opaque {
+ display: block;
+ color: #e06c75;
+ margin: 4px 0;
+}
+
+
+
+/* --- Served-view markup ------------------------------------------------- */
+/* The panel renders the same view the terminal does, so these mirror its
+ rules. RCSS has no gap or flex-wrap, so rows that must wrap are block
+ containers of inline-block children rather than flex. */
+
+.me-view {
+ display: block;
+}
+.me-summary {
+ display: block;
+ margin-bottom: 10px;
+}
+
+/* Stat row: label in muted ink, value in primary; the dot carries identity so
+ the number never has to be coloured to mean something. */
+.me-stats {
+ display: block;
+ margin-bottom: 8px;
+}
+.me-stat {
+ display: inline-block;
+ background-color: transparent;
+ border: 1px #111823;
+ border-radius: 6px;
+ padding: 3px 7px;
+ margin: 0px 8px 4px 0px;
+ cursor: pointer;
+}
+.me-stat:hover {
+ background-color: #16202c;
+ border: 1px #2c3b4d;
+}
+.me-stat-dot {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 4px;
+ margin-right: 5px;
+}
+.me-stat-label {
+ color: #8fa3b8;
+ font-size: 0.72em;
+ margin-right: 5px;
+}
+.me-stat-value {
+ color: #e8eef6;
+ font-size: 0.9em;
+}
+
+/* Proportion bar: thin, with a 2px surface gap between segments. */
+.me-propbar {
+ display: flex;
+ height: 6px;
+ margin-bottom: 10px;
+}
+.me-propseg {
+ height: 6px;
+ border-radius: 3px;
+ margin-right: 2px;
+}
+.me-series-1 {
+ background-color: #3987e5;
+}
+.me-series-2 {
+ background-color: #d95926;
+}
+
+/* Breadcrumb */
+.me-crumb {
+ display: block;
+ margin-bottom: 8px;
+ font-size: 0.8em;
+}
+.me-crumb-root {
+ display: inline-block;
+ background-color: transparent;
+ border: 0px #111823;
+ padding: 0px;
+ color: #6f9fd8;
+ cursor: pointer;
+}
+.me-crumb-root:hover {
+ color: #cfe4ff;
+}
+.me-crumb-sep {
+ display: inline-block;
+ color: #4c5d70;
+ margin: 0px 5px;
+}
+.me-crumb-here {
+ color: #e4ecf5;
+}
+
+/* Mission picker */
+.me-mission-list {
+ display: block;
+ margin-bottom: 8px;
+ padding: 4px;
+ border: 1px #223040;
+ border-radius: 6px;
+}
+.me-mission-list.collapsed {
+ display: none;
+}
+.me-mission-row {
+ display: block;
+ width: 100%;
+ text-align: left;
+ background-color: transparent;
+ border: 0px #111823;
+ border-radius: 5px;
+ color: #9fb3c8;
+ padding: 5px 9px;
+ font-size: 0.85em;
+ cursor: pointer;
+}
+.me-mission-row:hover {
+ background-color: #2c4158;
+ color: #ffffff;
+}
+
+/* Module reference */
+.me-module-list {
+ display: block;
+}
+.me-module-list.collapsed {
+ display: none;
+}
+.me-module {
+ display: block;
+ padding: 6px 0px;
+ border-bottom: 1px #1d2938;
+}
+.me-module-head {
+ display: block;
+}
+.me-module-name {
+ color: #cfe4ff;
+ font-size: 0.85em;
+ margin-right: 8px;
+}
+.me-module-desc {
+ color: #55677a;
+ font-size: 0.7em;
+}
+.me-module-row {
+ display: block;
+ margin-top: 4px;
+}
+.me-module-key {
+ display: inline-block;
+ width: 62px;
+ color: #55677a;
+ font-size: 0.65em;
+}
+
+/* Chips: one colour per kind, never cycled. */
+.me-chip {
+ display: inline-block;
+ background-color: #16283c;
+ border: 1px #33517a;
+ color: #8fbcff;
+ border-radius: 9px;
+ padding: 1px 7px;
+ margin: 0px 4px 3px 0px;
+ font-size: 0.68em;
+}
+.me-chip-req {
+ background-color: #1c242e;
+ border: 1px #3a4754;
+ color: #97a5b4;
+}
+.me-chip-stmt {
+ background-color: #12241a;
+ border: 1px #2e6b40;
+ color: #7fd79a;
+}
+.me-chip-build {
+ background-color: #1d1b2e;
+ border: 1px #4a3f7a;
+ color: #b3a8f0;
+}
+
+/* Dependency graph */
+.me-graph {
+ display: block;
+}
+.me-graph-row {
+ display: block;
+ padding: 3px 0px 3px 8px;
+ border-left: 1px #1c2836;
+}
+.me-graph-node {
+ display: inline-block;
+ background-color: transparent;
+ border: 0px #111823;
+ color: #9fb3c8;
+ padding: 1px 4px;
+ border-radius: 4px;
+ font-size: 0.8em;
+ cursor: pointer;
+}
+.me-graph-node:hover {
+ background-color: #24384f;
+ color: #cfe4ff;
+}
+.me-graph-arrow {
+ display: inline-block;
+ color: #4c5d70;
+ font-size: 0.68em;
+ margin: 0px 3px;
+}
+
+/* Graph: two readings of the same set, and the module-selection highlight. */
+.me-graph-modes {
+ display: block;
+ margin-bottom: 6px;
+}
+.me-graph-mode {
+ display: inline-block;
+ background-color: transparent;
+ border: 1px #263544;
+ color: #8fa3b8;
+ border-radius: 5px;
+ padding: 1px 9px;
+ margin-right: 4px;
+ font-size: 0.68em;
+ cursor: pointer;
+}
+.me-graph-mode-on {
+ background-color: #1d2d40;
+ border: 1px #3a5f8a;
+ color: #cfe4ff;
+}
+.me-graph.collapsed {
+ display: none;
+}
+/* Selecting a module lights its steps wherever they are used. Identity is the
+ name: eight modules cannot be told apart by hue, so one accent is enough. */
+.me-owned {
+ background-color: #24384f;
+ color: #cfe4ff;
+ border-radius: 3px;
+}
+.me-node-on {
+ border: 1px #8fbcff;
+}
+
+/* The whole Graph section is a served-terminal reading: RmlUi draws neither
+ the SVG nor a code block, and the flat roll-call that remains is not worth
+ the room on a game HUD. The markup still arrives; the widget marks the
+ section, and the passes stay hidden on their own so unhiding it degrades to
+ the roll-call rather than to a blank frame. */
+.me-hidden,
+.me-graph-graph,
+.me-graph-mermaid,
+.me-graph-mode {
+ display: none;
+}
diff --git a/modules/missions/rml_widgets/mission_editor.rml b/modules/missions/rml_widgets/mission_editor.rml
new file mode 100644
index 00000000000..84b0dd5ed06
--- /dev/null
+++ b/modules/missions/rml_widgets/mission_editor.rml
@@ -0,0 +1,27 @@
+
+
+
+ Mission Editor
+
+
+
+
+
+ Mission Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/missions/types/missions.lua b/modules/missions/types/missions.lua
index e7ca0b5a7f3..07905fa4fe0 100644
--- a/modules/missions/types/missions.lua
+++ b/modules/missions/types/missions.lua
@@ -1,10 +1,19 @@
---@meta actions
+
+--- Mission-runtime types: the trigger engine's descriptors and the authoring
--- DSL's chain/condition/effect shapes. The DSL surface is dot-only AND
--- closure-free: every chain step is a plain call with parens, no colon
--- methods, no metatables, and no function bodies in mission files — effects
--- are lazy objects built by named verbs. Mission files must load identically
--- in the synced sandbox (which strips rawset) and in busted.
+--- Domain aliases: the DSL's typed parameters. These names are LOAD-BEARING
+--- beyond the checker — the mission kit derives its semantic model from them
+--- (alias name -> slot semantic, literal unions -> editor enums), so a verb
+--- annotated with these types is understood by the editor without kit code.
+---@alias UnitDefName string unit def name, e.g. "armpw"
+---@alias ObjectiveName string
+
--- The mission bus vocabulary, CLOSED BY TYPE: every event name that may
--- cross the bus is a member of this alias. Engine callins are one producer,
--- modules are another — same kind of string, one type. Adding an event
@@ -77,7 +86,7 @@
--- A unit-def reference produced by the injected UnitDef verb. Carries the
--- name only; resolution to an id happens where Spring exists.
---@class MissionUnitDefRef
----@field name string
+---@field name UnitDefName
--- The injected Team.Player handle. Demo rule: resolves to the first human
--- team at mission load.
diff --git a/modules/missions/types/trigger_policy.lua b/modules/missions/types/trigger_policy.lua
new file mode 100644
index 00000000000..002cc6f0301
--- /dev/null
+++ b/modules/missions/types/trigger_policy.lua
@@ -0,0 +1,33 @@
+---@meta policy trigger
+
+--- The trigger-file authoring environment: the statements and handles only
+--- a mission speaks. Actions a module can perform live in its own
+--- types/actions.lua, where every grammar can read them.
+---
+--- The trigger-file authoring environment. These globals exist in no real
+--- scope: mission_loader injects them into each triggers/*.lua sandbox (the
+--- sandbox IS the API surface). This meta file mirrors that injection so the
+--- language server sees what a trigger file sees.
+
+---Start a trigger chain with its arming condition. Chain more conditions
+---with .When(...), effects with .Do(...), behavior with .Once(...).
+---@param condition MissionCondition
+---@return TriggerChain
+function When(condition) end
+
+---Objective handle: .Complete() builds the effect side, .IsComplete() the
+---condition side.
+---@param name ObjectiveName
+---@return MissionObjective
+function Objective(name) end
+
+---Unit-def reference by name; resolution to an id happens where Spring exists.
+---@param name UnitDefName
+---@return MissionUnitDefRef
+function UnitDef(name) end
+
+---@type { Player: MissionTeam }
+Team = {}
+
+---@type MissionMatchFlow
+MatchFlow = {}
diff --git a/modules/missions/widgets/mission_bridge.lua b/modules/missions/widgets/mission_bridge.lua
new file mode 100644
index 00000000000..ef8893ff9d9
--- /dev/null
+++ b/modules/missions/widgets/mission_bridge.lua
@@ -0,0 +1,201 @@
+-- Authoring tool: nothing here should cost a player who is not editing.
+if not Spring.Utilities.IsDevMode() then
+ return
+end
+
+local widget = widget ---@type Widget
+
+function widget:GetInfo()
+ return {
+ name = "Mission Bridge",
+ desc = "Headless game side of the mission editor: publishes domains, samples live state for the terminals, hot-reloads the mission on artifact regeneration",
+ author = "Beyond All Reason",
+ date = "July 2026",
+ license = "GNU GPL, v2 or later",
+ layer = 0,
+ enabled = true,
+ }
+end
+
+-- No UI here: terminals (RmlUi panel, VS Code, browser) render the served
+-- view; this widget contributes what only the game knows and runs whether or not a panel is open.
+
+local VIEW_PATH = "modules/missions/.editor/mission_view.json"
+local DOMAINS_PATH = "modules/missions/.editor/domains.json"
+local STATE_PATH = "modules/missions/.editor/state.json"
+local ACTIVE_PATH = "modules/missions/.editor/active_mission.json"
+local POLL_SECONDS = 0.5
+local LIVE_SAMPLE_SECONDS = 1.0
+
+-- Engine-provided in the widget env (system.lua whitelists it). Do NOT
+-- VFS.Include json.lua: it reads `local base = _G`, nil in widget sandboxes.
+local Json = Json
+if not Json then
+ return
+end
+
+local lastGeneration = nil
+local pollAccumulator = 0
+local liveAccumulator = 0
+local probes = nil
+
+local function writeJson(path, value)
+ Spring.CreateDir("modules/missions/.editor")
+ local handle = io.open(path, "w")
+ if not handle then
+ Spring.Echo("[mission_bridge] cannot write " .. path)
+ return
+ end
+ handle:write(Json.encode(value))
+ handle:close()
+end
+
+--------------------------------------------------------------------------------
+-- Domains: dropdown data only the game knows (UnitDefNames). Published once;
+-- serve folds it into the next generation.
+--------------------------------------------------------------------------------
+
+-- The payload depends on nothing else: the def set (contiguous 1..n, same
+-- assumption the ipairs walk below makes) and the locale that named them.
+local function domainsStamp()
+ local locale = Spring.I18N and Spring.I18N.getLocale and Spring.I18N.getLocale() or "?"
+ return #UnitDefs .. ":" .. tostring(locale)
+end
+
+local function publishDomains()
+ local stamp = domainsStamp()
+ local onDisk = VFS.LoadFile(DOMAINS_PATH, VFS.RAW)
+ -- Same inputs, same bytes: skip the walk, the sort and the 100 KB write.
+ if onDisk and onDisk:match('"v"%s*:%s*"([^"]*)"') == stamp then
+ return
+ end
+ local units = {}
+ -- ipairs(UnitDefs) is the idiom that actually iterates the engine proxy.
+ for _, def in ipairs(UnitDefs) do
+ if def.name then
+ local human = def.translatedHumanName or def.name
+ -- Untranslated units leak raw i18n keys (units.names.x); fall back.
+ if human:find("^units%.names%.") then
+ human = def.name
+ end
+ units[#units + 1] = { value = def.name, label = human .. " [" .. def.name .. "]" }
+ end
+ end
+ table.sort(units, function(a, b)
+ return a.value < b.value
+ end)
+ writeJson(DOMAINS_PATH, { v = stamp, units = units })
+end
+
+--------------------------------------------------------------------------------
+-- Live state: sample what the view manifest asks for, publish for every
+-- terminal. Unarmed = no values — a pawn count without a running mission would read as progress.
+--------------------------------------------------------------------------------
+
+local function countFinishedUnits(unitDefName)
+ local def = UnitDefNames[unitDefName]
+ if not def then
+ return nil
+ end
+ local count = 0
+ for _, unitID in ipairs(Spring.GetTeamUnitsByDefs(Spring.GetMyTeamID(), def.id) or {}) do
+ if not Spring.GetUnitIsBeingBuilt(unitID) then
+ count = count + 1
+ end
+ end
+ return count
+end
+
+local function sampleLive()
+ if not probes or #probes == 0 then
+ return
+ end
+ local armed = Spring.GetGameRulesParam("mission_active") == 1
+ local values = {}
+ if armed then
+ for _, probe in ipairs(probes) do
+ if probe.kind == "unit_count" and probe.unit_def then
+ local have = countFinishedUnits(probe.unit_def)
+ if have then
+ local need = math.floor(probe.need or 0)
+ local done = have >= need
+ values[probe.key] = {
+ text = have .. "/" .. need,
+ state = done and "done" or "pending",
+ pct = need > 0 and math.min(1, have / need) or (done and 1 or 0),
+ }
+ end
+ elseif probe.kind == "objective" and probe.objective then
+ local done = Spring.GetGameRulesParam("objective_" .. probe.objective) == 1
+ values[probe.key] = { text = done and "✓" or "–", state = done and "done" or "pending", pct = done and 1 or 0 }
+ end
+ end
+ end
+ writeJson(STATE_PATH, { t = os.time and os.time() or 0, armed = armed, values = values })
+end
+
+--------------------------------------------------------------------------------
+-- Follow the file: on a new artifact generation, refresh the probe manifest
+-- and hot-reload the running mission so the game tracks every accepted edit.
+--------------------------------------------------------------------------------
+
+local function pollArtifact(dt)
+ pollAccumulator = pollAccumulator + dt
+ if pollAccumulator < POLL_SECONDS then
+ return
+ end
+ pollAccumulator = 0
+ local text = VFS.LoadFile(VIEW_PATH, VFS.RAW_FIRST)
+ if not text then
+ return
+ end
+ local generation = text:match('"generation"%s*:%s*(%d+)')
+ if generation == lastGeneration then
+ return
+ end
+ -- serve writes the artifact non-atomically. A torn read must not consume the
+ -- generation, or this manifest never catches up — and must not reload.
+ local ok, view = pcall(Json.decode, text)
+ if not ok or type(view) ~= "table" then
+ return
+ end
+ local firstSight = lastGeneration == nil
+ lastGeneration = generation
+ probes = view.live or probes
+ -- The reload is synced and broadcast, but the edited files are local, so
+ -- one client's watcher must never drive it in a shared game.
+ if
+ not firstSight
+ and Spring.GetGameRulesParam("mission_active") == 1
+ and Spring.Utilities.Gametype.IsSinglePlayer()
+ then
+ Spring.SendCommands("luarules mission reload")
+ end
+end
+
+local lastMissionName = nil
+
+---Tell serve which mission is armed so it can re-scope the editor
+---(--missions-root mode): picker click retargets form, probes, Edit button.
+local function publishActiveMission()
+ local name = Spring.GetGameRulesParam("mission_name")
+ if name == lastMissionName or type(name) ~= "string" then
+ return
+ end
+ lastMissionName = name
+ writeJson(ACTIVE_PATH, { name = name })
+end
+
+function widget:Update(dt)
+ pollArtifact(dt)
+ liveAccumulator = liveAccumulator + dt
+ if liveAccumulator >= LIVE_SAMPLE_SECONDS then
+ liveAccumulator = 0
+ publishActiveMission()
+ sampleLive()
+ end
+end
+
+function widget:Initialize()
+ publishDomains()
+end