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(" '''" .. 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