Module:BSESA Mission Simulator

From MicrasWiki
Jump to navigationJump to search

Documentation for this module may be created at Module:BSESA Mission Simulator/doc

-- Module:BSESA Mission Simulator
-- Deterministic, data-driven mission simulation for MicrasWiki/Scribunto.
-- Mission N begins only after mission N-1 has completed.

local p = {}
local data = mw.loadData('Module:BSESA Mission Simulator/data')

local cfg = data.config
local bodies = data.bodies
local missions = data.missions

local DAY = 86400
local G = 6.67430e-11
local PI2 = math.pi * 2

local MONTHS = {'Atosiel', 'Thalassiel', 'Opsitheiel'}
local ZODIAC = {
    Atosiel = {'Atosien','Eosena','Micrasha','Pyreska','Indomin'},
    Thalassiel = {'Chrysen','Thalassian','Nephelia','Glinaeus','Noctien'},
    Opsitheiel = {'Opsithia','Stygian','Faunian','Silenian','Catosien'},
}

local function clamp(x, lo, hi)
    if x < lo then return lo end
    if x > hi then return hi end
    return x
end

local function num(v, fallback)
    v = tonumber(v)
    if v == nil then return fallback end
    return v
end

local function yes(v)
    if v == true then return true end
    if v == false or v == nil then return false end
    v = mw.ustring.lower(mw.text.trim(tostring(v)))
    return v == '1' or v == 'yes' or v == 'true' or v == 'y' or v == 'on'
end

local function comma(n, decimals)
    if n == nil or n ~= n or n == math.huge or n == -math.huge then return '—' end
    decimals = decimals or 0
    local sign = n < 0 and '-' or ''
    n = math.abs(n)
    local s = string.format('%.' .. decimals .. 'f', n)
    local whole, frac = s:match('^(%d+)(.*)$')
    local out = whole
    while true do
        local changed
        out, changed = out:gsub('^(%d+)(%d%d%d)', '%1,%2')
        if changed == 0 then break end
    end
    return sign .. out .. frac
end

local function durationText(seconds)
    seconds = math.max(0, seconds or 0)
    if seconds < 60 then return string.format('%.0f sec', seconds) end
    if seconds < 3600 then return string.format('%.1f min', seconds / 60) end
    if seconds < DAY * 2 then return string.format('%.1f hr', seconds / 3600) end
    if seconds < DAY * 365 then return string.format('%.1f days', seconds / DAY) end
    return string.format('%.2f years', seconds / (DAY * 365.2425))
end

local function utc(ts)
    return os.date('!%Y-%m-%d %H:%M UTC', math.floor(ts))
end

local function psscParts(ts)
    local absoluteDays = (ts - cfg.psscEpochUnix) / DAY
    local year = math.floor(absoluteDays / cfg.daysPerPSSCYear)
    local rem = absoluteDays - year * cfg.daysPerPSSCYear
    if rem < 0 then rem = rem + cfg.daysPerPSSCYear end
    local dayOfYear = math.floor(rem) + 1
    local monthIndex = math.floor((dayOfYear - 1) / 61) + 1
    local month = MONTHS[monthIndex] or MONTHS[1]
    local dayInMonth = ((dayOfYear - 1) % 61) + 1
    local zodiacIndex
    if dayInMonth <= 12 then zodiacIndex = 1
    elseif dayInMonth <= 24 then zodiacIndex = 2
    elseif dayInMonth <= 36 then zodiacIndex = 3
    elseif dayInMonth <= 48 then zodiacIndex = 4
    else zodiacIndex = 5 end
    local secondsOfDay = (rem - math.floor(rem)) * DAY
    local hour = math.floor(secondsOfDay / 3600)
    local minute = math.floor((secondsOfDay - hour * 3600) / 60 + 0.5)
    if minute >= 60 then hour = hour + 1; minute = 0 end
    return {
        year = year, dayOfYear = dayOfYear, month = month,
        dayInMonth = dayInMonth, zodiac = ZODIAC[month][zodiacIndex],
        hour = hour % 24, minute = minute,
    }
end

local function pssc(ts)
    local d = psscParts(ts)
    return string.format('%d, %s %d (%s), %d PSSC · %02d:%02d',
        d.dayOfYear, d.month, d.dayInMonth, d.zodiac, d.year, d.hour, d.minute)
end

local function vecAdd(a,b) return {a[1]+b[1], a[2]+b[2], a[3]+b[3]} end
local function vecSub(a,b) return {a[1]-b[1], a[2]-b[2], a[3]-b[3]} end
local function vecScale(a,s) return {a[1]*s, a[2]*s, a[3]*s} end
local function vecLength(a) return math.sqrt(a[1]*a[1] + a[2]*a[2] + a[3]*a[3]) end
local function vecLerp(a,b,t) return {a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t, a[3]+(b[3]-a[3])*t} end

local function absolutePSSCDays(ts)
    return (ts - cfg.psscEpochUnix) / DAY
end

local function orbitPosition(name, ts, cache, visiting)
    cache = cache or {}
    if cache[name] then return cache[name] end
    if name == 'Atos' then cache[name] = {0,0,0}; return cache[name] end
    local b = bodies[name]
    if not b then return nil end
    visiting = visiting or {}
    if visiting[name] then return nil end
    visiting[name] = true
    local parentPos = {0,0,0}
    if b.parent and b.parent ~= 'Atos' then
        parentPos = orbitPosition(b.parent, ts, cache, visiting) or parentPos
    end
    local sma = num(b.sma, 0)
    local peri = num(b.peri, sma)
    local apo = num(b.apo, sma)
    local ecc = 0
    if apo + peri > 0 then ecc = math.abs(apo - peri) / (apo + peri) end
    local period = math.max(num(b.period, 1), 1e-9)
    local theta = num(b.phase, 0) + absolutePSSCDays(ts) / period * PI2
    local radiusAU = sma
    local denom = 1 + ecc * math.cos(theta)
    if denom ~= 0 then radiusAU = sma * (1 - ecc * ecc) / denom end
    local inc = math.rad(num(b.inc, 0))
    local localPos = {
        math.cos(theta) * radiusAU,
        math.sin(theta) * math.sin(inc) * radiusAU,
        math.sin(theta) * math.cos(inc) * radiusAU,
    }
    cache[name] = vecAdd(parentPos, localPos)
    visiting[name] = nil
    return cache[name]
end

local function distanceKm(aName, bName, tsA, tsB)
    local a = orbitPosition(aName, tsA or tsB or os.time(), {})
    local b = orbitPosition(bName, tsB or tsA or os.time(), {})
    if not a or not b then return nil end
    return vecLength(vecSub(b,a)) * cfg.auKm
end

local function interceptFlight(segment, departUnix)
    local speed = math.max(num(segment.speedKmS, 1), 1e-9)
    local origin = orbitPosition(segment.from, departUnix, {})
    if not origin then return nil, 'Unknown origin: ' .. tostring(segment.from) end
    local firstTarget = orbitPosition(segment.to, departUnix, {})
    if not firstTarget then return nil, 'Unknown destination: ' .. tostring(segment.to) end
    local seconds = vecLength(vecSub(firstTarget, origin)) * cfg.auKm / speed
    for _ = 1, 8 do
        local target = orbitPosition(segment.to, departUnix + seconds, {})
        if not target then break end
        local newSeconds = vecLength(vecSub(target, origin)) * cfg.auKm / speed
        if math.abs(newSeconds - seconds) < 0.01 then seconds = newSeconds; break end
        seconds = newSeconds
    end
    local minSeconds = num(segment.minimumDays, 0) * DAY
    seconds = math.max(seconds, minSeconds)
    local arrivalTarget = orbitPosition(segment.to, departUnix + seconds, {})
    local pathKm = arrivalTarget and vecLength(vecSub(arrivalTarget, origin)) * cfg.auKm or speed * seconds
    return {seconds=seconds, distanceKm=pathKm, speedKmS=speed, model='moving-target intercept'}
end

local function hohmannFlight(segment, departUnix)
    local a = bodies[segment.from]
    local b = bodies[segment.to]
    if not a or not b or a.parent ~= 'Atos' or b.parent ~= 'Atos' then
        return nil, 'Hohmann mode requires two bodies orbiting Atos directly.'
    end
    local r1 = num(a.sma, 0) * cfg.auKm * 1000
    local r2 = num(b.sma, 0) * cfg.auKm * 1000
    local transferA = (r1 + r2) / 2
    local mu = G * cfg.atosMassKg
    local seconds = math.pi * math.sqrt((transferA ^ 3) / mu)
    seconds = math.max(seconds, num(segment.minimumDays, 0) * DAY)
    local ellipseB = math.sqrt(math.max(r1 * r2, 0))
    local circumference = math.pi * (3 * (transferA + ellipseB) - math.sqrt((3*transferA + ellipseB) * (transferA + 3*ellipseB)))
    return {seconds=seconds, distanceKm=(circumference / 2) / 1000, model='idealized Hohmann transfer'}
end

local function flightFor(segment, departUnix)
    if segment.model == 'fixed' or segment.days then
        local seconds = math.max(num(segment.days, 0), num(segment.minimumDays, 0)) * DAY
        local d = nil
        if segment.from and segment.to and segment.from ~= segment.to then
            d = distanceKm(segment.from, segment.to, departUnix, departUnix + seconds)
        end
        return {seconds=seconds, distanceKm=d, model='fixed mission plan'}
    elseif segment.model == 'hohmann' then
        local f, err = hohmannFlight(segment, departUnix)
        if f then return f end
        local fallback = interceptFlight(segment, departUnix)
        if fallback then fallback.warning = err; return fallback end
        return {seconds=DAY, distanceKm=0, model='error fallback', warning=err}
    else
        local f, err = interceptFlight(segment, departUnix)
        if f then return f end
        return {seconds=math.max(num(segment.minimumDays, 1),1)*DAY, distanceKm=0, model='error fallback', warning=err}
    end
end

local function segmentDuration(segment, startUnix)
    if segment.kind == 'travel' then
        return flightFor(segment, startUnix)
    end
    return {seconds=math.max(num(segment.days, 0), 0) * DAY, model='scheduled'}
end

local function buildSchedule()
    local schedule = {}
    local cursor = cfg.startUnix
    for missionIndex, mission in ipairs(missions) do
        local ms = {
            mission = mission, index = missionIndex,
            startUnix = cursor, segments = {}, totalDistanceKm = 0,
        }
        local segCursor = cursor
        for segmentIndex, segment in ipairs(mission.segments or {}) do
            local calc = segmentDuration(segment, segCursor)
            local ss = {
                segment = segment, index = segmentIndex,
                startUnix = segCursor,
                endUnix = segCursor + calc.seconds,
                durationSeconds = calc.seconds,
                distanceKm = calc.distanceKm,
                speedKmS = calc.speedKmS,
                model = calc.model,
                warning = calc.warning,
            }
            if calc.distanceKm then ms.totalDistanceKm = ms.totalDistanceKm + calc.distanceKm end
            ms.segments[#ms.segments+1] = ss
            segCursor = ss.endUnix
        end
        ms.endUnix = segCursor
        ms.durationSeconds = ms.endUnix - ms.startUnix
        schedule[#schedule+1] = ms
        cursor = ms.endUnix -- strict chain: next mission cannot begin earlier
    end
    return schedule
end

local function stateAt(schedule, now)
    if now < cfg.startUnix then return 'planned', nil, nil end
    for _, ms in ipairs(schedule) do
        if now < ms.startUnix then return 'queued', nil, nil end
        if now < ms.endUnix then
            for _, ss in ipairs(ms.segments) do
                if now < ss.endUnix then return 'active', ms, ss end
            end
            return 'active', ms, ms.segments[#ms.segments]
        end
    end
    return 'complete', schedule[#schedule], nil
end

local STATUS = {
    planned = {label='Planned', bg='#e8e8e8', fg='#333'},
    queued = {label='Queued', bg='#eee6cf', fg='#5b4611'},
    active = {label='Active', bg='#d9edf7', fg='#174f69'},
    complete = {label='Completed', bg='#dff0d8', fg='#285b24'},
}

local function missionStatus(ms, now)
    if now < ms.startUnix then return 'queued' end
    if now >= ms.endUnix then return 'complete' end
    return 'active'
end

local function kindLabel(kind)
    local labels = {
        preparation='Preparation', travel='Travel', operations='Operations',
        settlement='Settlement activation', closeout='Closeout', hold='Hold',
    }
    return labels[kind] or mw.language.getContentLanguage():ucfirst(kind or 'Phase')
end

local function settlementEvaluation(mission)
    local s = mission.settlement
    if not s then return nil end
    local checks = {
        {'Ratified civil charter', s.civilCharter},
        {'Defined relationship with Bassaridia Vaeringheim', s.nationalRelationship},
        {'Guaranteed life support and evacuation', s.lifeSupportEvacuation},
        {'Lawful supply and transport agreements', s.lawfulSupplyTransport},
        {'Environmental and sacred-site protections', s.environmentalSacredProtection},
        {'Local administration and dispute resolution', s.localAdministration},
        {'Medical and quarantine procedures', s.medicalQuarantine},
        {'Communications with Micras', s.micrasCommunications},
        {'Commercial ownership and public-infrastructure rules', s.ownershipInfrastructure},
    }
    local passed = 0
    for _, c in ipairs(checks) do if c[2] then passed = passed + 1 end end
    return {checks=checks, passed=passed, total=#checks, qualifies=(passed == #checks)}
end

local function currentTravelMetrics(ss, now)
    if not ss or ss.segment.kind ~= 'travel' then return nil end
    local fraction = clamp((now - ss.startUnix) / math.max(ss.durationSeconds, 1), 0, 1)
    local seg = ss.segment
    local fromPos = seg.from and orbitPosition(seg.from, ss.startUnix, {}) or nil
    local toPos = seg.to and orbitPosition(seg.to, ss.endUnix, {}) or nil
    local craftPos = fromPos and toPos and vecLerp(fromPos, toPos, fraction) or nil
    local micrasPos = craftPos and orbitPosition('Micras', now, {}) or nil
    local signalKm = craftPos and micrasPos and vecLength(vecSub(craftPos, micrasPos)) * cfg.auKm or nil
    local total = ss.distanceKm or 0
    return {
        fraction=fraction,
        travelledKm=total*fraction,
        remainingKm=total*(1-fraction),
        signalKm=signalKm,
        signalSeconds=signalKm and signalKm/cfg.lightSpeedKmS or nil,
    }
end

local function addCell(row, text, attrs)
    local cell = row:tag('td')
    if attrs then cell:attr(attrs) end
    cell:wikitext(text or '')
    return cell
end

local function badge(parent, status)
    local s = STATUS[status] or STATUS.queued
    parent:tag('span')
        :css({display='inline-block', padding='2px 7px', ['border-radius']='10px', ['font-size']='90%', ['font-weight']='bold', background=s.bg, color=s.fg})
        :wikitext(s.label)
end

local function progressBar(parent, fraction, label)
    fraction = clamp(fraction or 0, 0, 1)
    local wrap = parent:tag('div'):css({position='relative', height='18px', background='#ececec', border='1px solid #bbb', ['border-radius']='3px', overflow='hidden', ['min-width']='110px'})
    wrap:tag('div'):css({height='100%', width=string.format('%.2f%%', fraction*100), background='#6ba5c7'})
    wrap:tag('div'):css({position='absolute', top='0', left='0', right='0', ['text-align']='center', ['font-size']='11px', ['line-height']='18px', color='#111'}):wikitext(label or string.format('%.1f%%',fraction*100))
end

local function renderHeader(root, schedule, now, state, activeMission, activeSegment)
    local box = root:tag('div'):css({border='1px solid #9aa7b1', background='#f7f9fa', padding='10px', ['margin-bottom']='10px'})
    box:tag('div'):css({['font-size']='125%', ['font-weight']='bold'}):wikitext(cfg.campaignName)
    local statusLine = box:tag('div'):css({['margin-top']='4px'})
    badge(statusLine, state == 'complete' and 'complete' or (state == 'active' and 'active' or 'planned'))
    statusLine:wikitext(" &nbsp; '''" .. pssc(now) .. "''' (" .. utc(now) .. ')')
    if state == 'planned' then
        box:tag('div'):css({['margin-top']='6px'}):wikitext("Campaign begins '''" .. pssc(cfg.startUnix) .. "''' (" .. utc(cfg.startUnix) .. '), in ' .. durationText(cfg.startUnix-now) .. '.')
    elseif state == 'active' and activeMission then
        box:tag('div'):css({['margin-top']='6px'}):wikitext("'''Current mission:''' [[#" .. activeMission.mission.id .. '|' .. activeMission.mission.id .. ' — ' .. activeMission.mission.name .. ']]')
        if activeSegment then
            box:tag('div'):wikitext("'''Current phase:''' " .. kindLabel(activeSegment.segment.kind) .. ' — ' .. activeSegment.segment.label)
            progressBar(box, (now-activeSegment.startUnix)/math.max(activeSegment.durationSeconds,1), string.format('%.1f%%',clamp((now-activeSegment.startUnix)/math.max(activeSegment.durationSeconds,1),0,1)*100))
            box:tag('div'):css({['font-size']='90%', ['margin-top']='3px'}):wikitext("'''Phase ETA:''' " .. pssc(activeSegment.endUnix) .. ' · ' .. durationText(activeSegment.endUnix-now) .. ' remaining')
        end
    else
        box:tag('div'):css({['margin-top']='6px'}):wikitext("All " .. #schedule .. ' missions in the configured sequence have completed.')
    end
    box:tag('div'):css({['font-size']='85%', color='#555', ['margin-top']='6px'}):wikitext('The schedule is deterministic: every mission begins at the exact completion time of the preceding mission. Reordering or changing a duration in the data module automatically recalculates every later launch.')
end

local function renderCurrentTelemetry(root, ms, ss, now)
    if not ms or not ss then return end
    local card = root:tag('div'):css({border='1px solid #aab7c2', padding='9px', ['margin-bottom']='10px', background='#fff'})
    card:tag('div'):css({['font-weight']='bold', ['font-size']='110%'}):wikitext('Live mission telemetry')
    card:tag('div'):wikitext("'''" .. ms.mission.id .. ' — ' .. ms.mission.name .. "'''")
    card:tag('div'):wikitext("'''Vehicle:''' " .. ms.mission.vehicle .. ' · ' .. "'''Programme:''' " .. ms.mission.programme)
    card:tag('div'):wikitext("'''Objective:''' " .. ms.mission.objective)
    card:tag('div'):css({['margin-top']='5px'}):wikitext("'''Operational note:''' " .. (ss.segment.note or 'No additional note.'))
    if ss.segment.kind == 'travel' then
        local t = currentTravelMetrics(ss, now)
        local tableNode = card:tag('table'):addClass('wikitable'):css({width='100%', ['margin-top']='7px'})
        local r1 = tableNode:tag('tr')
        r1:tag('th'):wikitext('Route'); r1:tag('td'):wikitext((ss.segment.from or '—') .. ' → ' .. (ss.segment.to or '—'))
        local r2 = tableNode:tag('tr')
        r2:tag('th'):wikitext('Model'); r2:tag('td'):wikitext(ss.model .. (ss.speedKmS and (' at ' .. comma(ss.speedKmS,2) .. ' km/s') or ''))
        local r3 = tableNode:tag('tr')
        r3:tag('th'):wikitext('Nominal distance'); r3:tag('td'):wikitext(comma(ss.distanceKm or 0,0) .. ' km')
        local r4 = tableNode:tag('tr')
        r4:tag('th'):wikitext('Travelled / remaining'); r4:tag('td'):wikitext(comma(t.travelledKm,0) .. ' km / ' .. comma(t.remainingKm,0) .. ' km')
        local r5 = tableNode:tag('tr')
        r5:tag('th'):wikitext('One-way signal delay to Micras'); r5:tag('td'):wikitext(t.signalSeconds and durationText(t.signalSeconds) or '—')
        if ss.warning then card:tag('div'):css({color='#8a4b00'}):wikitext("'''Calculation warning:''' " .. ss.warning) end
    end
    local settlement = settlementEvaluation(ms.mission)
    if settlement and ss.segment.kind == 'settlement' then
        local title = settlement.qualifies and 'Settlement doctrine satisfied' or 'Not yet a qualifying settlement'
        card:tag('div'):css({['font-weight']='bold', ['margin-top']='8px'}):wikitext(title .. ' (' .. settlement.passed .. '/' .. settlement.total .. ')')
        local ul = card:tag('ul')
        for _, c in ipairs(settlement.checks) do
            ul:tag('li'):wikitext((c[2] and '✓ ' or '✗ ') .. c[1])
        end
    end
end

local function renderQueue(root, schedule, now, futureRows)
    root:tag('h3'):wikitext('Mission queue')
    local tableNode = root:tag('table'):addClass('wikitable sortable'):css({width='100%', ['font-size']='90%'})
    local hr = tableNode:tag('tr')
    for _, h in ipairs({'Mission','Programme / vehicle','Status','Start','Completion','Duration','Distance'}) do hr:tag('th'):wikitext(h) end
    local futureCount = 0
    for _, ms in ipairs(schedule) do
        local status = missionStatus(ms, now)
        if status ~= 'queued' or futureCount < futureRows then
            if status == 'queued' then futureCount = futureCount + 1 end
            local tr = tableNode:tag('tr'):attr('id', ms.mission.id)
            addCell(tr, "'''" .. ms.mission.id .. "'''<br>" .. ms.mission.name)
            addCell(tr, ms.mission.programme .. '<br><small>' .. ms.mission.vehicle .. '</small>')
            local st = tr:tag('td'); badge(st, status)
            addCell(tr, pssc(ms.startUnix) .. '<br><small>' .. utc(ms.startUnix) .. '</small>')
            addCell(tr, pssc(ms.endUnix) .. '<br><small>' .. utc(ms.endUnix) .. '</small>')
            addCell(tr, durationText(ms.durationSeconds))
            addCell(tr, ms.totalDistanceKm > 0 and (comma(ms.totalDistanceKm,0) .. ' km') or 'Local / fixed')
        end
    end
end

local function eventRows(ms)
    local events = {}
    events[#events+1] = {time=ms.startUnix, text='Mission sequence authority transferred to ' .. ms.mission.id .. '; preparation begins after completion of the preceding mission.'}
    for _, ss in ipairs(ms.segments) do
        local seg = ss.segment
        events[#events+1] = {time=ss.startUnix, text=kindLabel(seg.kind) .. ' begins: ' .. seg.label .. '. ' .. (seg.note or '')}
        if ss.durationSeconds >= DAY then
            events[#events+1] = {time=ss.startUnix + ss.durationSeconds/2, text='Mid-phase review: ' .. seg.label .. ' is at 50% of scheduled duration; navigation, safety, communications, and programme constraints remain under review.'}
        end
        local ending
        if seg.kind == 'travel' then
            ending = 'Arrival milestone reached at ' .. (seg.to or 'destination') .. '; ' .. comma(ss.distanceKm or 0,0) .. ' km recorded under the ' .. ss.model .. ' model.'
        else
            ending = kindLabel(seg.kind) .. ' completed: ' .. seg.label .. '.'
        end
        events[#events+1] = {time=ss.endUnix, text=ending}
    end
    events[#events+1] = {time=ms.endUnix, text=ms.mission.id .. ' formally completed; the next queued mission is now eligible to begin.'}
    table.sort(events, function(a,b) return a.time < b.time end)
    return events
end

local function renderLog(root, ms, now, maxEntries)
    if not ms then return end
    root:tag('h3'):wikitext('Mission log — ' .. ms.mission.id)
    local events = eventRows(ms)
    local shown = {}
    local nextEvent = nil
    for _, e in ipairs(events) do
        if e.time <= now then shown[#shown+1] = e elseif not nextEvent then nextEvent = e end
    end
    local first = math.max(1, #shown - maxEntries + 1)
    local tableNode = root:tag('table'):addClass('wikitable'):css({width='100%', ['font-size']='90%'})
    local hr = tableNode:tag('tr'); hr:tag('th'):wikitext('Mission time'); hr:tag('th'):wikitext('Log entry')
    if #shown == 0 then
        local r = tableNode:tag('tr'); addCell(r, pssc(ms.startUnix)); addCell(r, 'Mission has not begun.')
    else
        for i=first,#shown do
            local e=shown[i]; local r=tableNode:tag('tr')
            addCell(r, pssc(e.time) .. '<br><small>' .. utc(e.time) .. '</small>')
            addCell(r, e.text)
        end
    end
    if nextEvent then
        local r=tableNode:tag('tr'):css({background='#f6f6f6'})
        addCell(r, pssc(nextEvent.time) .. '<br><small>' .. utc(nextEvent.time) .. '</small>')
        addCell(r, "''Next scheduled event:'' " .. nextEvent.text)
    end
end

local function renderTechnicalNote(root)
    local note = root:tag('div'):css({['font-size']='85%', color='#555', border='1px solid #ddd', background='#fafafa', padding='7px', ['margin-top']='10px'})
    note:wikitext("'''Display behaviour:''' Lua evaluates the mission state from the current Unix time whenever MediaWiki parses this page. The simulated clock continues mathematically between visits, but the visible page does not animate in the browser. Refreshing or purging the page recalculates the display. Administrators can install a no-cache extension or a small JavaScript gadget for a visibly ticking countdown.")
end

local function selectMission(schedule, id)
    if not id or id == '' then return nil end
    for _, ms in ipairs(schedule) do if ms.mission.id == id then return ms end end
    return nil
end

function p.main(frame)
    local args = frame.args
    local parent = frame:getParent()
    if parent then
        for k,v in pairs(parent.args) do if args[k] == nil or args[k] == '' then args[k] = v end end
    end
    local now = num(args.now, os.time())
    local schedule = buildSchedule()
    local state, activeMission, activeSegment = stateAt(schedule, now)
    local requested = selectMission(schedule, args.mission)
    local root = mw.html.create('div'):addClass('besa-mission-simulator')
    renderHeader(root, schedule, now, state, activeMission, activeSegment)
    if activeMission and activeSegment then renderCurrentTelemetry(root, activeMission, activeSegment, now) end
    if requested then
        renderLog(root, requested, now, num(args.maxlog, cfg.maxLogEntries))
    else
        renderQueue(root, schedule, now, num(args.future, cfg.defaultFutureRows))
        renderLog(root, activeMission or schedule[1], now, num(args.maxlog, cfg.maxLogEntries))
    end
    renderTechnicalNote(root)
    return tostring(root)
end

function p.status(frame)
    local now = num(frame.args.now, os.time())
    local schedule = buildSchedule()
    local state, ms, ss = stateAt(schedule, now)
    if state == 'planned' then
        return 'Planned — begins ' .. pssc(cfg.startUnix)
    elseif state == 'complete' then
        return 'Programme sequence completed ' .. pssc(schedule[#schedule].endUnix)
    end
    local fraction = ss and clamp((now-ss.startUnix)/math.max(ss.durationSeconds,1),0,1) or 0
    return string.format('%s — %s — %s (%.1f%%)', ms.mission.id, ms.mission.name, ss.segment.label, fraction*100)
end

function p.date(frame)
    local ts = num(frame.args[1] or frame.args.now, os.time())
    return pssc(ts)
end

function p.distance(frame)
    local from = frame.args.from or frame.args[1]
    local to = frame.args.to or frame.args[2]
    local ts = num(frame.args.now or frame.args[3], os.time())
    if not from or not to then return '<strong class="error">Supply from= and to=.</strong>' end
    local d = distanceKm(from, to, ts, ts)
    if not d then return '<strong class="error">Unknown body name.</strong>' end
    return comma(d,0) .. ' km'
end

function p.schedule(frame)
    local schedule = buildSchedule()
    local root = mw.html.create('div')
    renderQueue(root, schedule, num(frame.args.now, os.time()), num(frame.args.future, #schedule))
    return tostring(root)
end

return p