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