Module:Engineers: Difference between revisions
Jump to navigation
Jump to search
No edit summary |
No edit summary |
||
| Line 1: | Line 1: | ||
-- Module:Engineers | -- 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 | -- * Engineer Capability Comparison | ||
-- * Other generated Engineer reference views | |||
-- | -- | ||
-- This module adds | -- 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. 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. | -- See Elite Dangerous Field Manual:Adding Structured Data. | ||
| Line 18: | Line 23: | ||
local p = {} | local p = {} | ||
-- | |||
-- | ------------------------------------------------------------------------ | ||
-- 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: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- General helpers | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- Converts a plain | 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('&', '&') | |||
value = value:gsub('"', '"') | |||
value = value:gsub('<', '<') | |||
value = value:gsub('>', '>') | |||
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]] | ||
-- | -- | ||
-- wikilink(' | -- wikilink('Drag', 'Drag Seeker Missile Rack') | ||
-- | -- -> [[Drag Seeker Missile Rack|Drag]] | ||
-- | -- | ||
-- Existing | -- Existing wikitext links are left unchanged so article overrides remain | ||
-- | -- backwards-compatible. | ||
local function wikilink(value, target) | local function wikilink(value, target) | ||
if value | if not isNonEmpty(value) then | ||
return value | return value | ||
end | end | ||
| Line 62: | Line 126: | ||
end | end | ||
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: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
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) | -- 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 | |||
if display then | |||
if | table.insert(values, display) | ||
table.insert(values, | |||
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 | return result | ||
end | end | ||
-- | return | ||
d.prerequisite_engineer_display | |||
or 'None — available from the start' | |||
return d. | 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: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- Preferred simple structured format: | ||
-- | -- | ||
-- "unlock": { | -- "unlock": { | ||
| Line 177: | Line 378: | ||
-- } | -- } | ||
-- | -- | ||
-- | -- Output: | ||
-- | -- | ||
-- Deliver 1 unit of [[Meta-Alloys]] | -- Deliver 1 unit of [[Meta-Alloys]] | ||
-- | -- | ||
-- | -- | ||
-- | -- Quantity > 1: | ||
-- | -- | ||
-- | -- "unlock": { | ||
-- using unlock_requirement | -- "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( | 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 | end | ||
local | |||
-------------------------------------------------------------------- | |||
-- 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 | end | ||
-- | ------------------------------------------------------------------------ | ||
return d. | -- 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 | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- 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 function buildFields(d, pargs) | ||
local fields = {} | local fields = {} | ||
if d then | if d then | ||
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 | -- Automatically linked canonical fields | ||
-------------------------------------------------------------------- | -------------------------------------------------------------------- | ||
fields.system = wikilink(d.system | fields.system = | ||
wikilink(d.system) | |||
fields.body = | |||
fields. | 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 | 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: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- | -- | ||
-- | -- Example: | ||
-- | -- | ||
-- {{Engineer infobox | -- {{Engineer infobox | ||
-- |image = | -- |image = Replacement.png | ||
-- }} | -- }} | ||
-- | -- | ||
-- | -- Only explicitly populated values override canonical structured data. | ||
-- Empty parameters do not erase defaults. | |||
-- Empty parameters do not erase | |||
for _, f in ipairs(FIELD_ORDER) do | for _, f in ipairs(FIELD_ORDER) do | ||
if pargs[f.key] | 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. | ||
function p.infobox(frame) | function p.infobox(frame) | ||
local parent = frame:getParent() | local parent = | ||
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) | |||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
| Line 341: | Line 923: | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
local image = pargs.image | local image = | ||
pargs.image | |||
if (image | if not isNonEmpty(image) | ||
and d | and d | ||
and d.image | and isNonEmpty(d.image) then | ||
image = d.image | image = d.image | ||
end | end | ||
------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ||
-- Build Template:Infobox | -- 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 | 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 | -- 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}} | ||
-- | -- | ||
-- Engineer | -- 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}} | ||
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 | ||
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) | ||
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}} | ||
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('&', '&')
value = value:gsub('"', '"')
value = value:gsub('<', '<')
value = value:gsub('>', '>')
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