Module:Engineers: Difference between revisions

Current answers. Practical procedures. Reliable reference.
Jump to navigation Jump to search
No edit summary
No edit summary
Line 1: Line 1:
-- Module:Engineers
-- Module:Engineers
-- Reads canonical Engineer facts from Module:Data/Engineers/<Name> (JSON
-- content-model pages) and formats them for multiple consumers:
--
--
--  * Engineer infoboxes via Template:Engineer infobox
-- Canonical Engineer facts are stored in:
--  * List of Engineers
-- Module:Data/Engineers/<Name>
--
-- This module provides structured Engineer data to:
--
--  * Template:Engineer infobox
--  * Engineer directory tables
--  * Engineer Capability Comparison
--  * Engineer Capability Comparison
--  * Other generated Engineer reference views
--
--
-- This module adds appropriate internal wiki links when rendering content.
-- Structured JSON records should contain plain data rather than presentation
-- wikitext wherever practical. This module adds internal links and formatting.
--
--
-- Adding a new Engineer:
-- Adding a new Engineer:
--  1. Create Module:Data/Engineers/<Name>
--  1. Create Module:Data/Engineers/<Name> using the JSON content model
--  2. Use the same JSON schema as the existing Engineer records
--  2. Add the Engineer's name to Module:Data/Engineers/index
--  3. Append the Engineer name to Module:Data/Engineers/index
--  3. Use {{Engineer infobox}} on the Engineer article
--
--
-- See Elite Dangerous Field Manual:Adding Structured Data.
-- See Elite Dangerous Field Manual:Adding Structured Data.
Line 18: Line 23:
local p = {}
local p = {}


-- Field order and labels shared by both the structured-data path and the
 
-- manual fallback path.
------------------------------------------------------------------------
-- Infobox field configuration
------------------------------------------------------------------------
 
local FIELD_ORDER = {
local FIELD_ORDER = {
{ key = 'engineer_type', label = 'Engineer type', group = 'ENGINEER' },
{ key = 'engineer_type', label = 'Engineer type', group = 'ENGINEER' },
Line 38: Line 46:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- Utility functions
-- General helpers
------------------------------------------------------------------------
------------------------------------------------------------------------


-- Converts a plain structured-data value into an internal wiki link.
local function isNonEmpty(value)
return value ~= nil and value ~= ''
end
 
 
-- Escape a value before inserting it into an HTML-style attribute inside
-- generated wikitext.
local function escapeAttribute(value)
if value == nil then
return ''
end
 
value = tostring(value)
 
value = value:gsub('&', '&amp;')
value = value:gsub('"', '&quot;')
value = value:gsub('<', '&lt;')
value = value:gsub('>', '&gt;')
 
return value
end
 
 
-- Convert a number to a display value with thousands separators.
local function formatNumber(value)
if value == nil then
return nil
end
 
local text = tostring(value)
 
local sign, integer, fraction =
text:match('^([%-]?)(%d+)(%.?%d*)$')
 
if not integer then
return text
end
 
local reversed =
integer
:reverse()
:gsub('(%d%d%d)', '%1,')
:reverse()
 
if reversed:sub(1, 1) == ',' then
reversed = reversed:sub(2)
end
 
return sign .. reversed .. fraction
end
 
 
------------------------------------------------------------------------
-- Internal link helpers
------------------------------------------------------------------------
 
-- Converts a plain value into an internal wiki link.
--
--
-- Examples:
-- Examples:
--
--
--  wikilink('Deciat')
--  wikilink('Deciat')
--     -> [[Deciat]]
--       -> [[Deciat]]
--
--
--  wikilink('Farseer Inc')
--  wikilink('Drag', 'Drag Seeker Missile Rack')
--     -> [[Farseer Inc]]
--       -> [[Drag Seeker Missile Rack|Drag]]
--
--
-- Existing wiki links are left untouched so explicit/manual overrides
-- Existing wikitext links are left unchanged so article overrides remain
-- remain safe.
-- backwards-compatible.
local function wikilink(value, target)
local function wikilink(value, target)
if value == nil or value == '' then
if not isNonEmpty(value) then
return value
return value
end
end
Line 62: Line 126:
end
end


-- Already contains a wiki link.
if value:find('%[%[') then
if value:find('%[%[') then
return value
return value
Line 74: Line 137:


return '[[' .. target .. '|' .. value .. ']]'
return '[[' .. target .. '|' .. value .. ']]'
end
-- Supports either a simple string:
--
-- "Frame Shift Drive"
--
-- or an object:
--
-- {
--    "name": "Drag",
--    "target": "Drag Seeker Missile Rack"
-- }
--
-- This gives structured data the ability to use a short display name while
-- linking to a differently named article.
local function formatLinkedValue(value)
if value == nil then
return nil
end
if type(value) == 'string' then
return wikilink(value)
end
if type(value) ~= 'table' then
return tostring(value)
end
local display =
value.display
or value.name
or value.item
or value.target
if not display then
return nil
end
if value.link == false then
return display
end
local target =
value.target
or value.name
or value.item
or display
local result = wikilink(display, target)
if isNonEmpty(value.note) then
result = result .. ' ' .. value.note
end
return result
end
local function joinLinkedList(values)
if values == nil then
return nil
end
if type(values) ~= 'table' then
return formatLinkedValue(values)
end
local result = {}
for _, value in ipairs(values) do
local formatted = formatLinkedValue(value)
if isNonEmpty(formatted) then
table.insert(result, formatted)
end
end
if #result == 0 then
return nil
end
return table.concat(result, ', ')
end
end


Line 82: Line 228:


local function loadRecord(name)
local function loadRecord(name)
local title = mw.title.new('Data/Engineers/' .. name, 'Module')
local title =
mw.title.new('Data/Engineers/' .. name, 'Module')


if not title then
if not title then
Line 94: Line 241:
end
end


local ok, data = pcall(mw.text.jsonDecode, content)
local ok, data =
pcall(mw.text.jsonDecode, content)


if not ok then
if not ok or type(data) ~= 'table' then
return nil
return nil
end
end
Line 105: Line 253:


local function loadIndex()
local function loadIndex()
local title = mw.title.new('Data/Engineers/index', 'Module')
local title =
local content = title and title:getContent()
mw.title.new('Data/Engineers/index', 'Module')
 
local content =
title and title:getContent()


if not content then
if not content then
Line 112: Line 263:
end
end


local ok, data = pcall(mw.text.jsonDecode, content)
local ok, data =
pcall(mw.text.jsonDecode, content)


if not ok then
if not ok or type(data) ~= 'table' then
return {}
return {}
end
end
Line 126: Line 278:
------------------------------------------------------------------------
------------------------------------------------------------------------


-- Structured Engineer records should preferably store specialties as an
-- array:
--
-- "specialties": [
--    "Frame Shift Drive",
--    "Power Plant",
--    "Thrusters"
-- ]
--
-- This allows each module name to become its own internal link.
local function joinSpecialties(d, linked)
local function joinSpecialties(d, linked)
if not d or not d.specialties then
if not d or not d.specialties then
Line 141: Line 283:
end
end


if type(d.specialties) == 'table' then
-- Legacy string values are preserved as-is. A string could contain
local values = {}
-- descriptive prose rather than one clean module/entity name.
if type(d.specialties) ~= 'table' then
return d.specialties
end
 
if linked then
return joinLinkedList(d.specialties)
end
 
local values = {}
 
for _, specialty in ipairs(d.specialties) do
if type(specialty) == 'string' then
table.insert(values, specialty)
elseif type(specialty) == 'table' then
local display =
specialty.display
or specialty.name
or specialty.item
or specialty.target


for _, specialty in ipairs(d.specialties) do
if display then
if linked then
table.insert(values, display)
table.insert(values, wikilink(specialty))
else
table.insert(values, specialty)
end
end
end
end
if #values == 0 then
return nil
end
return table.concat(values, ', ')
end
------------------------------------------------------------------------
-- Prerequisite formatting
------------------------------------------------------------------------
-- Infobox version: includes the optional prerequisite note.
local function formatPrerequisite(d)
if not d then
return nil
end
if d.prerequisite_engineer then
local result =
wikilink(d.prerequisite_engineer)
if isNonEmpty(d.prerequisite_engineer_note) then
result =
result
.. ' '
.. d.prerequisite_engineer_note
end
end


return table.concat(values, ', ')
return result
end
end


-- Preserve legacy/string values unchanged. These may contain descriptive
return
-- prose rather than a single module name, so automatically linking the
d.prerequisite_engineer_display
-- entire string would not always be appropriate.
or 'None — available from the start'
return d.specialties
end
 
 
-- Directory version: the table already explains the general referral rule,
-- so it keeps the individual row concise.
local function formatDirectoryReferral(d)
if not d then
return 'None'
end
 
if d.prerequisite_engineer then
return wikilink(d.prerequisite_engineer)
end
 
return
d.directory_prerequisite_display
or 'None'
end
end


Line 166: Line 370:
------------------------------------------------------------------------
------------------------------------------------------------------------


-- Supports a newer structured unlock format while retaining compatibility
-- Preferred simple structured format:
-- with the existing unlock_requirement prose field.
--
-- Example JSON:
--
--
-- "unlock": {
-- "unlock": {
Line 177: Line 378:
-- }
-- }
--
--
-- Produces:
-- Output:
--
--
-- Deliver 1 unit of [[Meta-Alloys]]
-- Deliver 1 unit of [[Meta-Alloys]]
--
--
-- Optional fields:
--
--
-- "unit": "unit"
-- Quantity > 1:
-- "unit_plural": "units"
-- "item_target": "Meta-Alloys"
--
--
-- For unlock requirements that do not fit this structure cleanly, continue
-- "unlock": {
-- using unlock_requirement as a display string.
--    "action": "Provide",
--    "quantity": 50,
--    "item": "Classified Scan Databanks"
-- }
--
-- Output:
--
-- Provide 50 [[Classified Scan Databanks]]
--
--
-- Credit-only requirement:
--
-- "unlock": {
--    "action": "Pay",
--    "credits": 500000
-- }
--
-- Output:
--
-- Pay 500,000 CR
--
--
-- Credit-value item requirement:
--
-- "unlock": {
--    "action": "Deliver",
--    "credits": 100000,
--    "item": "Bounty Vouchers",
--    "credits_worth": true
-- }
--
-- Output:
--
-- Deliver 100,000 CR worth of [[Bounty Vouchers]]
--
--
-- Complex requirements may continue using unlock_requirement until they are
-- given a richer structured representation.
local function formatUnlock(d)
local function formatUnlock(d)
if not d then
if not d then
Line 194: Line 429:
end
end


if type(d.unlock) == 'table' and d.unlock.item then
local unlock = d.unlock
local action = d.unlock.action or 'Deliver'
 
local quantity = d.unlock.quantity or 1
if type(unlock) == 'table' then
local quantityNumber = tonumber(quantity)
 
--------------------------------------------------------------------
-- Item-based requirement
--------------------------------------------------------------------
 
if unlock.item then
local action =
unlock.action
or 'Deliver'
 
local item =
formatLinkedValue {
display =
unlock.item_display
or unlock.item,
 
target =
unlock.item_target
or unlock.item
}
 
-- Credit value of an item/voucher.
if unlock.credits and unlock.credits_worth then
return string.format(
'%s %s CR worth of %s',
action,
formatNumber(unlock.credits),
item
)
end
 
local quantity = unlock.quantity
 
if quantity ~= nil then
local quantityNumber =
tonumber(quantity)
 
-- A singular item defaults to the natural "1 unit of X".
if quantityNumber == 1 then
local unit =
unlock.unit
or 'unit'
 
return string.format(
'%s %s %s of %s',
action,
tostring(quantity),
unit,
item
)
end


local singularUnit = d.unlock.unit or 'unit'
-- An explicit unit may still be supplied where wording such
local pluralUnit = d.unlock.unit_plural or (singularUnit .. 's')
-- as "10 units of mined Osmium" is important.
if isNonEmpty(unlock.unit) then
local plural =
unlock.unit_plural
or (unlock.unit .. 's')


local unit
return string.format(
'%s %s %s of %s',
action,
tostring(quantity),
plural,
item
)
end


if quantityNumber == 1 then
-- Default plural form: "Deliver 50 Gold",
unit = singularUnit
-- "Provide 50 Classified Scan Databanks", etc.
else
return string.format(
unit = pluralUnit
'%s %s %s',
action,
tostring(quantity),
item
)
end
 
return action .. ' ' .. item
end
end


local item = wikilink(
 
d.unlock.item,
--------------------------------------------------------------------
d.unlock.item_target or d.unlock.item
-- Credit-only requirement
--------------------------------------------------------------------
 
if unlock.credits then
local action =
unlock.action
or 'Pay'
 
return string.format(
'%s %s CR',
action,
formatNumber(unlock.credits)
)
end
 
 
--------------------------------------------------------------------
-- Explicit fallback display
--------------------------------------------------------------------
 
if isNonEmpty(unlock.display) then
return unlock.display
end
end
 
 
------------------------------------------------------------------------
-- Legacy compatibility
------------------------------------------------------------------------
 
return d.unlock_requirement
end
 
 
------------------------------------------------------------------------
-- Location formatting for generated directories
------------------------------------------------------------------------
 
local function formatLocation(d)
if not d then
return '—'
end
 
local lines = {}
 
if isNonEmpty(d.facility) then
table.insert(
lines,
wikilink(d.facility)
)
)
end


return string.format(
if isNonEmpty(d.system) then
'%s %s %s of %s',
table.insert(
action,
lines,
tostring(quantity),
"'''" .. wikilink(d.system) .. "'''"
unit,
item
)
)
end
end


-- Backward compatibility with existing records.
------------------------------------------------------------------------
return d.unlock_requirement
-- Optional location notes
------------------------------------------------------------------------
 
local notes = {}
 
if type(d.location_notes) == 'table' then
for _, note in ipairs(d.location_notes) do
if isNonEmpty(note) then
table.insert(notes, note)
end
end
 
elseif isNonEmpty(d.location_note) then
table.insert(notes, d.location_note)
end
 
-- Automatically identify Colonia records unless the record already
-- supplied a Colonia note explicitly.
if d.colonia then
local hasColonia = false
 
for _, note in ipairs(notes) do
if tostring(note):lower() == 'colonia' then
hasColonia = true
break
end
end
 
if not hasColonia then
table.insert(notes, 'Colonia')
end
end
 
for _, note in ipairs(notes) do
table.insert(
lines,
'<small>' .. note .. '</small>'
)
end
 
if #lines == 0 then
return '—'
end
 
return table.concat(lines, '<br>')
end
 
 
------------------------------------------------------------------------
-- Access formatting for generated directories
------------------------------------------------------------------------
 
local function formatAccess(d)
if not d then
return '—'
end
 
local lines = {}
 
------------------------------------------------------------------------
-- Referral
------------------------------------------------------------------------
 
table.insert(
lines,
"'''Referral:''' "
.. formatDirectoryReferral(d)
)
 
 
------------------------------------------------------------------------
-- Meeting requirement
------------------------------------------------------------------------
 
local meeting =
d.meeting_requirement
or d.invitation_requirement
 
if isNonEmpty(meeting) then
table.insert(
lines,
"'''Meet:''' " .. meeting
)
end
 
 
------------------------------------------------------------------------
-- Unlock
------------------------------------------------------------------------
 
local unlock =
formatUnlock(d)
 
if isNonEmpty(unlock) then
table.insert(
lines,
"'''Unlock:''' " .. unlock
)
end
 
 
------------------------------------------------------------------------
-- Optional access note
------------------------------------------------------------------------
 
if isNonEmpty(d.access_note) then
table.insert(
lines,
'<small>' .. d.access_note .. '</small>'
)
end
 
return table.concat(lines, '<br>')
end
end




------------------------------------------------------------------------
------------------------------------------------------------------------
-- Field construction
-- Engineering formatting for generated directories
------------------------------------------------------------------------
------------------------------------------------------------------------


-- Builds the display values used by an Engineer infobox.
-- Preferred schema:
--
-- "engineering": [
--    {
--        "grade": 5,
--        "modules": [
--            "Frame Shift Drive"
--        ]
--    },
--    {
--        "grade": 3,
--        "modules": [
--            "Sensors",
--            "Thrusters"
--        ]
--    }
-- ]
--
--
--
-- Structured data supplies the defaults. Explicit parameters supplied by
-- Live/Merc example:
-- an article override those defaults.
--
-- {
--    "grade": 5,
--    "live_only": true,
--    "merc": true,
--    "modules": [
--        "Cargo Rack"
--    ]
-- }
--
--
-- Custom-label example:
--
-- {
--    "label": "G3 — Live only; Merc",
--    "modules": [
--        {
--            "display": "Drag",
--            "target": "Drag Seeker Missile Rack"
--        }
--    ]
-- }
local function formatEngineering(d)
if not d then
return '—'
end
 
if type(d.engineering) == 'table' then
local rows = {}
 
for _, entry in ipairs(d.engineering) do
if type(entry) == 'table' then
local items =
entry.modules
or entry.items
 
local linkedItems =
joinLinkedList(items)
 
if isNonEmpty(linkedItems) then
local label
 
if isNonEmpty(entry.label) then
label = entry.label
 
elseif entry.grade ~= nil then
label =
'G'
.. tostring(entry.grade)
 
if entry.live_only then
label =
label
.. ' — Live only'
end
 
if entry.merc then
label =
label
.. '; Merc'
end
 
else
label = 'Engineering'
end
 
local row =
"'''" .. label .. ":''' "
.. linkedItems
 
if isNonEmpty(entry.note) then
row =
row
.. ' '
.. entry.note
end
 
table.insert(rows, row)
end
end
end
 
if #rows > 0 then
return table.concat(rows, '<br>')
end
end
 
 
------------------------------------------------------------------------
-- Transitional fallbacks
------------------------------------------------------------------------
 
if isNonEmpty(d.engineering_display) then
return d.engineering_display
end
 
return
joinSpecialties(d, true)
or '—'
end
 
 
------------------------------------------------------------------------
-- Infobox field construction
------------------------------------------------------------------------
 
local function buildFields(d, pargs)
local function buildFields(d, pargs)
local fields = {}
local fields = {}


if d then
if d then
-- Start with the canonical values.
for _, f in ipairs(FIELD_ORDER) do
for _, f in ipairs(FIELD_ORDER) do
fields[f.key] = d[f.key]
fields[f.key] =
d[f.key]
end
end


--------------------------------------------------------------------
--------------------------------------------------------------------
-- Automatically linked structured fields
-- Automatically linked canonical fields
--------------------------------------------------------------------
--------------------------------------------------------------------


fields.system = wikilink(d.system)
fields.system =
fields.body = wikilink(d.body)
wikilink(d.system)
fields.facility = wikilink(d.facility)
fields.region = wikilink(d.region)
fields.allegiance = wikilink(d.allegiance)


-- Each specialty/module gets its own internal link.
fields.body =
fields.specialties = joinSpecialties(d, true)
wikilink(d.body)


--------------------------------------------------------------------
fields.facility =
-- Prerequisite Engineer
wikilink(d.facility)
--------------------------------------------------------------------
 
fields.region =
wikilink(d.region)
 
fields.allegiance =
wikilink(d.allegiance)


if d.prerequisite_engineer then
fields.specialties =
local note = ''
joinSpecialties(d, true)


if d.prerequisite_engineer_note
fields.prerequisite_engineer =
and d.prerequisite_engineer_note ~= '' then
formatPrerequisite(d)
note = ' ' .. d.prerequisite_engineer_note
end


fields.prerequisite_engineer =
fields.unlock_requirement =
wikilink(d.prerequisite_engineer) .. note
formatUnlock(d)
else
fields.prerequisite_engineer =
d.prerequisite_engineer_display
or 'None — available from the start'
end


else
--------------------------------------------------------------------
--------------------------------------------------------------------
-- Unlock
-- No structured record: use article parameters
--------------------------------------------------------------------
--------------------------------------------------------------------


fields.unlock_requirement = formatUnlock(d)
else
-- No structured record exists yet. Fall back entirely to manually
-- supplied template parameters.
for _, f in ipairs(FIELD_ORDER) do
for _, f in ipairs(FIELD_ORDER) do
fields[f.key] = pargs[f.key]
fields[f.key] =
pargs[f.key]
end
end
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
Line 297: Line 874:
------------------------------------------------------------------------
------------------------------------------------------------------------
--
--
-- This preserves:
-- Example:
--
--
-- {{Engineer infobox
-- {{Engineer infobox
--  |image = DifferentPortrait.png
--  |image = Replacement.png
-- }}
-- }}
--
--
-- as a valid way to override one structured value without duplicating
-- Only explicitly populated values override canonical structured data.
-- the rest of the Engineer record.
-- Empty parameters do not erase defaults.
--
-- Empty parameters do not erase structured defaults.
for _, f in ipairs(FIELD_ORDER) do
for _, f in ipairs(FIELD_ORDER) do
if pargs[f.key] ~= nil and pargs[f.key] ~= '' then
if isNonEmpty(pargs[f.key]) then
fields[f.key] = pargs[f.key]
fields[f.key] =
pargs[f.key]
end
end
end
end
Line 324: Line 900:
--
--
-- Called by Template:Engineer infobox.
-- Called by Template:Engineer infobox.
--
-- If a canonical JSON record exists, it supplies the defaults.
-- Explicit template parameters override structured values.
-- If no JSON record exists, manual parameters are used instead.
function p.infobox(frame)
function p.infobox(frame)
local parent = frame:getParent()
local parent =
local pargs = parent and parent.args or {}
frame:getParent()


local name = pargs.name or mw.title.getCurrentTitle().text
local pargs =
local d = loadRecord(name)
parent and parent.args
or {}
 
local name =
pargs.name
or mw.title.getCurrentTitle().text
 
local d =
loadRecord(name)
 
local fields =
buildFields(d, pargs)


local fields = buildFields(d, pargs)


------------------------------------------------------------------------
------------------------------------------------------------------------
Line 341: Line 923:
------------------------------------------------------------------------
------------------------------------------------------------------------


local image = pargs.image
local image =
pargs.image


if (image == nil or image == '')
if not isNonEmpty(image)
and d
and d
and d.image
and isNonEmpty(d.image) then
and d.image ~= '' then
 
image = d.image
image = d.image
end
end


------------------------------------------------------------------------
------------------------------------------------------------------------
-- Build Template:Infobox parameters
-- Build Template:Infobox arguments
------------------------------------------------------------------------
------------------------------------------------------------------------


Line 362: Line 946:


for _, f in ipairs(FIELD_ORDER) do
for _, f in ipairs(FIELD_ORDER) do
local value = fields[f.key]
local value =
fields[f.key]


if value ~= nil and value ~= '' then
if isNonEmpty(value) then
n = n + 1
n = n + 1


if f.group then
if f.group then
args['group' .. n] = f.group
args['group' .. n] =
f.group
end
end


args['label' .. n] = f.label
args['label' .. n] =
args['row' .. n] = value
f.label
 
args['row' .. n] =
value
end
end
end
end
Line 384: Line 973:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- Engineer list
-- Engineer directory cell
------------------------------------------------------------------------
 
local function formatEngineerCell(frame, d, fallbackName)
local name =
d.name
or fallbackName
 
-- Use the existing Engineer table entry template when an image exists.
if isNonEmpty(d.image) then
return frame:expandTemplate {
title = 'Engineer table entry',
args = {
[1] = name,
[2] = d.image
}
}
end
 
return wikilink(name)
end
 
 
------------------------------------------------------------------------
-- Engineer type detection
------------------------------------------------------------------------
------------------------------------------------------------------------


-- {{#invoke:Engineers|list}}
local function isShipEngineer(d)
if not d then
return false
end
 
if d.engineer_scope == 'ship' then
return true
end
 
if type(d.engineer_type) == 'string'
and d.engineer_type:lower():find('ship', 1, true) then
 
return true
end
 
return false
end
 
 
------------------------------------------------------------------------
-- Generated Ship Engineer directory
------------------------------------------------------------------------
 
-- Usage:
--
--
-- Generates a sortable Engineer table from the canonical index.
-- {{#invoke:Engineers|shipDirectory}}
--
--
-- Engineer names, systems, and specialties are linked automatically.
-- Generates the full Ship Engineer reference table from the canonical JSON
-- index. All entity-style values are linked automatically where the schema
-- provides enough structure to identify them.
function p.shipDirectory(frame)
local rows = {}
 
for _, name in ipairs(loadIndex()) do
local d =
loadRecord(name)
 
if d and isShipEngineer(d) then
 
local engineer =
formatEngineerCell(
frame,
d,
name
)
 
local location =
formatLocation(d)
 
local access =
formatAccess(d)
 
local engineering =
formatEngineering(d)
 
local sortName =
escapeAttribute(
d.name or name
)
 
table.insert(
rows,
string.format(
'|-\n'
.. '| data-sort-value="%s" | %s\n'
.. '| %s\n'
.. '| %s\n'
.. '| %s',
sortName,
engineer,
location,
access,
engineering
)
)
end
end
 
return
'{| class="wikitable sortable" style="width:100%;"\n'
.. '! style="width:18%;" | Engineer\n'
.. '! style="width:15%;" | Location\n'
.. '! style="width:32%;" | Access requirements\n'
.. '! style="width:35%;" | Engineering offered\n'
.. table.concat(rows, '\n')
.. '\n|}'
end
 
 
------------------------------------------------------------------------
-- Simple Engineer list
------------------------------------------------------------------------
 
-- {{#invoke:Engineers|list}}
function p.list()
function p.list()
local rows = {}
local rows = {}


for _, name in ipairs(loadIndex()) do
for _, name in ipairs(loadIndex()) do
local d = loadRecord(name)
local d =
loadRecord(name)


if d then
if d then
Line 406: Line 1,109:
wikilink(d.system) or '—',
wikilink(d.system) or '—',
joinSpecialties(d, true) or '—',
joinSpecialties(d, true) or '—',
d.max_grade or 'Not yet confirmed'
d.max_grade
or 'Not yet confirmed'
)
)
)
)
Line 425: Line 1,129:


-- {{#invoke:Engineers|comparison}}
-- {{#invoke:Engineers|comparison}}
--
-- Generates a comparison table from canonical Engineer data.
--
-- Engineer names, specialties, prerequisite Engineers, and structured
-- unlock items are linked automatically.
function p.comparison()
function p.comparison()
local rows = {}
local rows = {}


for _, name in ipairs(loadIndex()) do
for _, name in ipairs(loadIndex()) do
local d = loadRecord(name)
local d =
loadRecord(name)


if d then
if d then
local prereq
if d.prerequisite_engineer then
prereq = wikilink(d.prerequisite_engineer)
else
prereq =
d.prerequisite_engineer_display
or 'None'
end
table.insert(
table.insert(
rows,
rows,
Line 452: Line 1,142:
'|-\n| %s || %s || %s || %s',
'|-\n| %s || %s || %s || %s',
wikilink(d.name or name),
wikilink(d.name or name),
joinSpecialties(d, true) or '—',
joinSpecialties(d, true)
prereq,
or '—',
formatUnlock(d) or 'Not yet confirmed'
formatDirectoryReferral(d)
or 'None',
formatUnlock(d)
or 'Not yet confirmed'
)
)
)
)
Line 473: Line 1,166:


-- {{#invoke:Engineers|count}}
-- {{#invoke:Engineers|count}}
--
-- Returns the number of canonical Engineer records currently present in
-- Module:Data/Engineers/index.
function p.count()
function p.count()
return tostring(#loadIndex())
return tostring(
#loadIndex()
)
end
end




return p
return p

Revision as of 00:44, 20 August 2026

Documentation for this module may be created at Module:Engineers/doc

-- Module:Engineers
--
-- Canonical Engineer facts are stored in:
-- Module:Data/Engineers/<Name>
--
-- This module provides structured Engineer data to:
--
--   * Template:Engineer infobox
--   * Engineer directory tables
--   * Engineer Capability Comparison
--   * Other generated Engineer reference views
--
-- Structured JSON records should contain plain data rather than presentation
-- wikitext wherever practical. This module adds internal links and formatting.
--
-- Adding a new Engineer:
--   1. Create Module:Data/Engineers/<Name> using the JSON content model
--   2. Add the Engineer's name to Module:Data/Engineers/index
--   3. Use {{Engineer infobox}} on the Engineer article
--
-- See Elite Dangerous Field Manual:Adding Structured Data.

local p = {}


------------------------------------------------------------------------
-- Infobox field configuration
------------------------------------------------------------------------

local FIELD_ORDER = {
	{ key = 'engineer_type', label = 'Engineer type', group = 'ENGINEER' },
	{ key = 'system', label = 'System' },
	{ key = 'body', label = 'Body' },
	{ key = 'facility', label = 'Facility' },
	{ key = 'region', label = 'Region' },
	{ key = 'allegiance', label = 'Allegiance' },
	{ key = 'specialties', label = 'Specialties' },
	{ key = 'max_grade', label = 'Maximum grade' },

	{ key = 'prerequisite_engineer', label = 'Prerequisite', group = 'ACCESS' },
	{ key = 'discovery_requirement', label = 'Discovery' },
	{ key = 'invitation_requirement', label = 'Invitation' },
	{ key = 'unlock_requirement', label = 'Unlock' },
}


------------------------------------------------------------------------
-- General helpers
------------------------------------------------------------------------

local function isNonEmpty(value)
	return value ~= nil and value ~= ''
end


-- Escape a value before inserting it into an HTML-style attribute inside
-- generated wikitext.
local function escapeAttribute(value)
	if value == nil then
		return ''
	end

	value = tostring(value)

	value = value:gsub('&', '&amp;')
	value = value:gsub('"', '&quot;')
	value = value:gsub('<', '&lt;')
	value = value:gsub('>', '&gt;')

	return value
end


-- Convert a number to a display value with thousands separators.
local function formatNumber(value)
	if value == nil then
		return nil
	end

	local text = tostring(value)

	local sign, integer, fraction =
		text:match('^([%-]?)(%d+)(%.?%d*)$')

	if not integer then
		return text
	end

	local reversed =
		integer
			:reverse()
			:gsub('(%d%d%d)', '%1,')
			:reverse()

	if reversed:sub(1, 1) == ',' then
		reversed = reversed:sub(2)
	end

	return sign .. reversed .. fraction
end


------------------------------------------------------------------------
-- Internal link helpers
------------------------------------------------------------------------

-- Converts a plain value into an internal wiki link.
--
-- Examples:
--
--   wikilink('Deciat')
--       -> [[Deciat]]
--
--   wikilink('Drag', 'Drag Seeker Missile Rack')
--       -> [[Drag Seeker Missile Rack|Drag]]
--
-- Existing wikitext links are left unchanged so article overrides remain
-- backwards-compatible.
local function wikilink(value, target)
	if not isNonEmpty(value) then
		return value
	end

	if type(value) ~= 'string' then
		return value
	end

	if value:find('%[%[') then
		return value
	end

	target = target or value

	if target == value then
		return '[[' .. value .. ']]'
	end

	return '[[' .. target .. '|' .. value .. ']]'
end


-- Supports either a simple string:
--
-- "Frame Shift Drive"
--
-- or an object:
--
-- {
--     "name": "Drag",
--     "target": "Drag Seeker Missile Rack"
-- }
--
-- This gives structured data the ability to use a short display name while
-- linking to a differently named article.
local function formatLinkedValue(value)
	if value == nil then
		return nil
	end

	if type(value) == 'string' then
		return wikilink(value)
	end

	if type(value) ~= 'table' then
		return tostring(value)
	end

	local display =
		value.display
		or value.name
		or value.item
		or value.target

	if not display then
		return nil
	end

	if value.link == false then
		return display
	end

	local target =
		value.target
		or value.name
		or value.item
		or display

	local result = wikilink(display, target)

	if isNonEmpty(value.note) then
		result = result .. ' ' .. value.note
	end

	return result
end


local function joinLinkedList(values)
	if values == nil then
		return nil
	end

	if type(values) ~= 'table' then
		return formatLinkedValue(values)
	end

	local result = {}

	for _, value in ipairs(values) do
		local formatted = formatLinkedValue(value)

		if isNonEmpty(formatted) then
			table.insert(result, formatted)
		end
	end

	if #result == 0 then
		return nil
	end

	return table.concat(result, ', ')
end


------------------------------------------------------------------------
-- Data loading
------------------------------------------------------------------------

local function loadRecord(name)
	local title =
		mw.title.new('Data/Engineers/' .. name, 'Module')

	if not title then
		return nil
	end

	local content = title:getContent()

	if not content then
		return nil
	end

	local ok, data =
		pcall(mw.text.jsonDecode, content)

	if not ok or type(data) ~= 'table' then
		return nil
	end

	return data
end


local function loadIndex()
	local title =
		mw.title.new('Data/Engineers/index', 'Module')

	local content =
		title and title:getContent()

	if not content then
		return {}
	end

	local ok, data =
		pcall(mw.text.jsonDecode, content)

	if not ok or type(data) ~= 'table' then
		return {}
	end

	return data
end


------------------------------------------------------------------------
-- Specialty formatting
------------------------------------------------------------------------

local function joinSpecialties(d, linked)
	if not d or not d.specialties then
		return nil
	end

	-- Legacy string values are preserved as-is. A string could contain
	-- descriptive prose rather than one clean module/entity name.
	if type(d.specialties) ~= 'table' then
		return d.specialties
	end

	if linked then
		return joinLinkedList(d.specialties)
	end

	local values = {}

	for _, specialty in ipairs(d.specialties) do
		if type(specialty) == 'string' then
			table.insert(values, specialty)
		elseif type(specialty) == 'table' then
			local display =
				specialty.display
				or specialty.name
				or specialty.item
				or specialty.target

			if display then
				table.insert(values, display)
			end
		end
	end

	if #values == 0 then
		return nil
	end

	return table.concat(values, ', ')
end


------------------------------------------------------------------------
-- Prerequisite formatting
------------------------------------------------------------------------

-- Infobox version: includes the optional prerequisite note.
local function formatPrerequisite(d)
	if not d then
		return nil
	end

	if d.prerequisite_engineer then
		local result =
			wikilink(d.prerequisite_engineer)

		if isNonEmpty(d.prerequisite_engineer_note) then
			result =
				result
				.. ' '
				.. d.prerequisite_engineer_note
		end

		return result
	end

	return
		d.prerequisite_engineer_display
		or 'None — available from the start'
end


-- Directory version: the table already explains the general referral rule,
-- so it keeps the individual row concise.
local function formatDirectoryReferral(d)
	if not d then
		return 'None'
	end

	if d.prerequisite_engineer then
		return wikilink(d.prerequisite_engineer)
	end

	return
		d.directory_prerequisite_display
		or 'None'
end


------------------------------------------------------------------------
-- Unlock formatting
------------------------------------------------------------------------

-- Preferred simple structured format:
--
-- "unlock": {
--     "action": "Deliver",
--     "quantity": 1,
--     "item": "Meta-Alloys"
-- }
--
-- Output:
--
-- Deliver 1 unit of [[Meta-Alloys]]
--
--
-- Quantity > 1:
--
-- "unlock": {
--     "action": "Provide",
--     "quantity": 50,
--     "item": "Classified Scan Databanks"
-- }
--
-- Output:
--
-- Provide 50 [[Classified Scan Databanks]]
--
--
-- Credit-only requirement:
--
-- "unlock": {
--     "action": "Pay",
--     "credits": 500000
-- }
--
-- Output:
--
-- Pay 500,000 CR
--
--
-- Credit-value item requirement:
--
-- "unlock": {
--     "action": "Deliver",
--     "credits": 100000,
--     "item": "Bounty Vouchers",
--     "credits_worth": true
-- }
--
-- Output:
--
-- Deliver 100,000 CR worth of [[Bounty Vouchers]]
--
--
-- Complex requirements may continue using unlock_requirement until they are
-- given a richer structured representation.
local function formatUnlock(d)
	if not d then
		return nil
	end

	local unlock = d.unlock

	if type(unlock) == 'table' then

		--------------------------------------------------------------------
		-- Item-based requirement
		--------------------------------------------------------------------

		if unlock.item then
			local action =
				unlock.action
				or 'Deliver'

			local item =
				formatLinkedValue {
					display =
						unlock.item_display
						or unlock.item,

					target =
						unlock.item_target
						or unlock.item
				}

			-- Credit value of an item/voucher.
			if unlock.credits and unlock.credits_worth then
				return string.format(
					'%s %s CR worth of %s',
					action,
					formatNumber(unlock.credits),
					item
				)
			end

			local quantity = unlock.quantity

			if quantity ~= nil then
				local quantityNumber =
					tonumber(quantity)

				-- A singular item defaults to the natural "1 unit of X".
				if quantityNumber == 1 then
					local unit =
						unlock.unit
						or 'unit'

					return string.format(
						'%s %s %s of %s',
						action,
						tostring(quantity),
						unit,
						item
					)
				end

				-- An explicit unit may still be supplied where wording such
				-- as "10 units of mined Osmium" is important.
				if isNonEmpty(unlock.unit) then
					local plural =
						unlock.unit_plural
						or (unlock.unit .. 's')

					return string.format(
						'%s %s %s of %s',
						action,
						tostring(quantity),
						plural,
						item
					)
				end

				-- Default plural form: "Deliver 50 Gold",
				-- "Provide 50 Classified Scan Databanks", etc.
				return string.format(
					'%s %s %s',
					action,
					tostring(quantity),
					item
				)
			end

			return action .. ' ' .. item
		end


		--------------------------------------------------------------------
		-- Credit-only requirement
		--------------------------------------------------------------------

		if unlock.credits then
			local action =
				unlock.action
				or 'Pay'

			return string.format(
				'%s %s CR',
				action,
				formatNumber(unlock.credits)
			)
		end


		--------------------------------------------------------------------
		-- Explicit fallback display
		--------------------------------------------------------------------

		if isNonEmpty(unlock.display) then
			return unlock.display
		end
	end


	------------------------------------------------------------------------
	-- Legacy compatibility
	------------------------------------------------------------------------

	return d.unlock_requirement
end


------------------------------------------------------------------------
-- Location formatting for generated directories
------------------------------------------------------------------------

local function formatLocation(d)
	if not d then
		return '—'
	end

	local lines = {}

	if isNonEmpty(d.facility) then
		table.insert(
			lines,
			wikilink(d.facility)
		)
	end

	if isNonEmpty(d.system) then
		table.insert(
			lines,
			"'''" .. wikilink(d.system) .. "'''"
		)
	end

	------------------------------------------------------------------------
	-- Optional location notes
	------------------------------------------------------------------------

	local notes = {}

	if type(d.location_notes) == 'table' then
		for _, note in ipairs(d.location_notes) do
			if isNonEmpty(note) then
				table.insert(notes, note)
			end
		end

	elseif isNonEmpty(d.location_note) then
		table.insert(notes, d.location_note)
	end

	-- Automatically identify Colonia records unless the record already
	-- supplied a Colonia note explicitly.
	if d.colonia then
		local hasColonia = false

		for _, note in ipairs(notes) do
			if tostring(note):lower() == 'colonia' then
				hasColonia = true
				break
			end
		end

		if not hasColonia then
			table.insert(notes, 'Colonia')
		end
	end

	for _, note in ipairs(notes) do
		table.insert(
			lines,
			'<small>' .. note .. '</small>'
		)
	end

	if #lines == 0 then
		return '—'
	end

	return table.concat(lines, '<br>')
end


------------------------------------------------------------------------
-- Access formatting for generated directories
------------------------------------------------------------------------

local function formatAccess(d)
	if not d then
		return '—'
	end

	local lines = {}

	------------------------------------------------------------------------
	-- Referral
	------------------------------------------------------------------------

	table.insert(
		lines,
		"'''Referral:''' "
			.. formatDirectoryReferral(d)
	)


	------------------------------------------------------------------------
	-- Meeting requirement
	------------------------------------------------------------------------

	local meeting =
		d.meeting_requirement
		or d.invitation_requirement

	if isNonEmpty(meeting) then
		table.insert(
			lines,
			"'''Meet:''' " .. meeting
		)
	end


	------------------------------------------------------------------------
	-- Unlock
	------------------------------------------------------------------------

	local unlock =
		formatUnlock(d)

	if isNonEmpty(unlock) then
		table.insert(
			lines,
			"'''Unlock:''' " .. unlock
		)
	end


	------------------------------------------------------------------------
	-- Optional access note
	------------------------------------------------------------------------

	if isNonEmpty(d.access_note) then
		table.insert(
			lines,
			'<small>' .. d.access_note .. '</small>'
		)
	end

	return table.concat(lines, '<br>')
end


------------------------------------------------------------------------
-- Engineering formatting for generated directories
------------------------------------------------------------------------

-- Preferred schema:
--
-- "engineering": [
--     {
--         "grade": 5,
--         "modules": [
--             "Frame Shift Drive"
--         ]
--     },
--     {
--         "grade": 3,
--         "modules": [
--             "Sensors",
--             "Thrusters"
--         ]
--     }
-- ]
--
--
-- Live/Merc example:
--
-- {
--     "grade": 5,
--     "live_only": true,
--     "merc": true,
--     "modules": [
--         "Cargo Rack"
--     ]
-- }
--
--
-- Custom-label example:
--
-- {
--     "label": "G3 — Live only; Merc",
--     "modules": [
--         {
--             "display": "Drag",
--             "target": "Drag Seeker Missile Rack"
--         }
--     ]
-- }
local function formatEngineering(d)
	if not d then
		return '—'
	end

	if type(d.engineering) == 'table' then
		local rows = {}

		for _, entry in ipairs(d.engineering) do
			if type(entry) == 'table' then
				local items =
					entry.modules
					or entry.items

				local linkedItems =
					joinLinkedList(items)

				if isNonEmpty(linkedItems) then
					local label

					if isNonEmpty(entry.label) then
						label = entry.label

					elseif entry.grade ~= nil then
						label =
							'G'
							.. tostring(entry.grade)

						if entry.live_only then
							label =
								label
								.. ' — Live only'
						end

						if entry.merc then
							label =
								label
								.. '; Merc'
						end

					else
						label = 'Engineering'
					end

					local row =
						"'''" .. label .. ":''' "
							.. linkedItems

					if isNonEmpty(entry.note) then
						row =
							row
								.. ' '
								.. entry.note
					end

					table.insert(rows, row)
				end
			end
		end

		if #rows > 0 then
			return table.concat(rows, '<br>')
		end
	end


	------------------------------------------------------------------------
	-- Transitional fallbacks
	------------------------------------------------------------------------

	if isNonEmpty(d.engineering_display) then
		return d.engineering_display
	end

	return
		joinSpecialties(d, true)
		or '—'
end


------------------------------------------------------------------------
-- Infobox field construction
------------------------------------------------------------------------

local function buildFields(d, pargs)
	local fields = {}

	if d then
		for _, f in ipairs(FIELD_ORDER) do
			fields[f.key] =
				d[f.key]
		end

		--------------------------------------------------------------------
		-- Automatically linked canonical fields
		--------------------------------------------------------------------

		fields.system =
			wikilink(d.system)

		fields.body =
			wikilink(d.body)

		fields.facility =
			wikilink(d.facility)

		fields.region =
			wikilink(d.region)

		fields.allegiance =
			wikilink(d.allegiance)

		fields.specialties =
			joinSpecialties(d, true)

		fields.prerequisite_engineer =
			formatPrerequisite(d)

		fields.unlock_requirement =
			formatUnlock(d)

	else
		--------------------------------------------------------------------
		-- No structured record: use article parameters
		--------------------------------------------------------------------

		for _, f in ipairs(FIELD_ORDER) do
			fields[f.key] =
				pargs[f.key]
		end
	end


	------------------------------------------------------------------------
	-- Explicit article overrides
	------------------------------------------------------------------------
	--
	-- Example:
	--
	-- {{Engineer infobox
	--  |image = Replacement.png
	-- }}
	--
	-- Only explicitly populated values override canonical structured data.
	-- Empty parameters do not erase defaults.
	for _, f in ipairs(FIELD_ORDER) do
		if isNonEmpty(pargs[f.key]) then
			fields[f.key] =
				pargs[f.key]
		end
	end

	return fields
end


------------------------------------------------------------------------
-- Engineer infobox
------------------------------------------------------------------------

-- {{#invoke:Engineers|infobox}}
--
-- Called by Template:Engineer infobox.
function p.infobox(frame)
	local parent =
		frame:getParent()

	local pargs =
		parent and parent.args
		or {}

	local name =
		pargs.name
		or mw.title.getCurrentTitle().text

	local d =
		loadRecord(name)

	local fields =
		buildFields(d, pargs)


	------------------------------------------------------------------------
	-- Image
	------------------------------------------------------------------------

	local image =
		pargs.image

	if not isNonEmpty(image)
		and d
		and isNonEmpty(d.image) then

		image = d.image
	end


	------------------------------------------------------------------------
	-- Build Template:Infobox arguments
	------------------------------------------------------------------------

	local args = {
		name = name,
		image = image
	}

	local n = 0

	for _, f in ipairs(FIELD_ORDER) do
		local value =
			fields[f.key]

		if isNonEmpty(value) then
			n = n + 1

			if f.group then
				args['group' .. n] =
					f.group
			end

			args['label' .. n] =
				f.label

			args['row' .. n] =
				value
		end
	end

	return frame:expandTemplate {
		title = 'Infobox',
		args = args
	}
end


------------------------------------------------------------------------
-- Engineer directory cell
------------------------------------------------------------------------

local function formatEngineerCell(frame, d, fallbackName)
	local name =
		d.name
		or fallbackName

	-- Use the existing Engineer table entry template when an image exists.
	if isNonEmpty(d.image) then
		return frame:expandTemplate {
			title = 'Engineer table entry',
			args = {
				[1] = name,
				[2] = d.image
			}
		}
	end

	return wikilink(name)
end


------------------------------------------------------------------------
-- Engineer type detection
------------------------------------------------------------------------

local function isShipEngineer(d)
	if not d then
		return false
	end

	if d.engineer_scope == 'ship' then
		return true
	end

	if type(d.engineer_type) == 'string'
		and d.engineer_type:lower():find('ship', 1, true) then

		return true
	end

	return false
end


------------------------------------------------------------------------
-- Generated Ship Engineer directory
------------------------------------------------------------------------

-- Usage:
--
-- {{#invoke:Engineers|shipDirectory}}
--
-- Generates the full Ship Engineer reference table from the canonical JSON
-- index. All entity-style values are linked automatically where the schema
-- provides enough structure to identify them.
function p.shipDirectory(frame)
	local rows = {}

	for _, name in ipairs(loadIndex()) do
		local d =
			loadRecord(name)

		if d and isShipEngineer(d) then

			local engineer =
				formatEngineerCell(
					frame,
					d,
					name
				)

			local location =
				formatLocation(d)

			local access =
				formatAccess(d)

			local engineering =
				formatEngineering(d)

			local sortName =
				escapeAttribute(
					d.name or name
				)

			table.insert(
				rows,
				string.format(
					'|-\n'
					.. '| data-sort-value="%s" | %s\n'
					.. '| %s\n'
					.. '| %s\n'
					.. '| %s',
					sortName,
					engineer,
					location,
					access,
					engineering
				)
			)
		end
	end

	return
		'{| class="wikitable sortable" style="width:100%;"\n'
		.. '! style="width:18%;" | Engineer\n'
		.. '! style="width:15%;" | Location\n'
		.. '! style="width:32%;" | Access requirements\n'
		.. '! style="width:35%;" | Engineering offered\n'
		.. table.concat(rows, '\n')
		.. '\n|}'
end


------------------------------------------------------------------------
-- Simple Engineer list
------------------------------------------------------------------------

-- {{#invoke:Engineers|list}}
function p.list()
	local rows = {}

	for _, name in ipairs(loadIndex()) do
		local d =
			loadRecord(name)

		if d then
			table.insert(
				rows,
				string.format(
					'|-\n| %s || %s || %s || %s',
					wikilink(d.name or name),
					wikilink(d.system) or '—',
					joinSpecialties(d, true) or '—',
					d.max_grade
						or 'Not yet confirmed'
				)
			)
		end
	end

	return
		'{| class="wikitable sortable"\n'
		.. '! Engineer !! System !! Specialties !! Maximum grade\n'
		.. table.concat(rows, '\n')
		.. '\n|}'
end


------------------------------------------------------------------------
-- Engineer capability comparison
------------------------------------------------------------------------

-- {{#invoke:Engineers|comparison}}
function p.comparison()
	local rows = {}

	for _, name in ipairs(loadIndex()) do
		local d =
			loadRecord(name)

		if d then
			table.insert(
				rows,
				string.format(
					'|-\n| %s || %s || %s || %s',
					wikilink(d.name or name),
					joinSpecialties(d, true)
						or '—',
					formatDirectoryReferral(d)
						or 'None',
					formatUnlock(d)
						or 'Not yet confirmed'
				)
			)
		end
	end

	return
		'{| class="wikitable sortable"\n'
		.. '! Engineer !! Specialties !! Prerequisite Engineer !! Unlock requirement\n'
		.. table.concat(rows, '\n')
		.. '\n|}'
end


------------------------------------------------------------------------
-- Engineer count
------------------------------------------------------------------------

-- {{#invoke:Engineers|count}}
function p.count()
	return tostring(
		#loadIndex()
	)
end


return p