Модуль:SpeciesStats
Для документации этого модуля может быть создана страница Модуль:SpeciesStats/doc
-- Модуль:SpeciesStats
-- Характеристики игровых рас для страницы «Особенности рас».
-- Все числа берутся из выгрузки бота IanComradeBot для текущего проекта
-- (Corvax, Goob, Wega, WL — проект выбирает Модуль:JsonPaths по {{#var:JsonPath}}
-- или по пространству имён страницы).
--
-- Используемые данные бота:
-- prototype/Species.json — список рас, roundStart, моб расы, название
-- prototype/DamageModifierSet.json — модификаторы получаемого урона
-- prototype/DamageType.json, prototype/DamageGroup.json — типы и группы урона
-- prototype/Reagent.json — кровь и газы для дыхания
-- prototype/Satiation.json — голод и жажда (новая схема, Corvax)
-- prototype/Body.json — органы (старая схема тела, Goob)
-- prototype/MetabolismStage.json, prototype/MetabolismGroup.json — что делает орган
-- prototype/MetabolizerType.json — типы метаболизма
-- component/*.json — компоненты моба расы (см. функции ниже)
-- loc.json — русская локализация
--
-- Функции для страницы:
-- {{#invoke:SpeciesStats|has|Arachnid}} — «1», если раса доступна игрокам в проекте
-- {{#invoke:SpeciesStats|has|Skeleton|any=1}} — «1», если раса вообще есть в проекте
-- {{#invoke:SpeciesStats|name|Arachnid}} — название расы
-- {{#invoke:SpeciesStats|racebox|Arachnid|file=Арахнид.png|link=Арахниды}} — карточка расы
-- {{#invoke:SpeciesStats|stats|Arachnid}} — разделы с характеристиками
-- {{#invoke:SpeciesStats|features|Arachnid|header=Особенности|notes=...}} — особенности (ручные строки + из данных)
-- {{#invoke:SpeciesStats|missing|Human, Arachnid, ...}} — расы проекта, которых нет в списке
-- {{#invoke:SpeciesStats|image|Arachnid}} — имя файла картинки расы (для шторки)
-- {{#invoke:SpeciesStats|nav|Human:Люди, ...}} — сетка иконок рас (сейчас на странице не используется)
-- {{#invoke:SpeciesStats|summary|Human:Люди, ...}} — сводная таблица рас (сейчас не используется)
--
-- КАК ПРАВИТЬ
-- * Цифры, названия и списки здесь не хранятся: они берутся из выгрузки бота и
-- обновляются сами. Если на странице устаревшая цифра, обычно нужно обновить
-- выгрузку бота, а не править модуль.
-- * Названия берутся из loc.json (ru-RU локализация репозитория). Если вместо
-- названия видно ID или английский текст, перевода нет в выгрузке.
-- * Текст на страницу выводят функции *Lines (basicLines, environmentLines,
-- combatLines, organLines, reagentLines, otherLines). Чтобы поменять формулировку
-- строки, найдите её текст поиском и правьте только текст в кавычках.
-- * Особенности, которые видно по компонентам моба, перечислены в таблице FEATURES.
-- Чтобы добавить новую, допишите туда строку по образцу (см. комментарий у таблицы).
-- * Особенность, которой нет в данных, пишется не здесь, а на самой странице
-- в параметре notes= у нужной расы.
-- * Расцветка: зелёный — положительное (лечение, преимущества), красный —
-- отрицательное (урон, недостатки). Для урона используйте takenText(), для лечения —
-- healText(), для фраз — tone(). Режим сравнения с человеком — COMPARE_WITH_HUMAN.
-- * Порядок групп урона и органов задают GROUP_ORDER и ORGAN_ORDER ниже.
-- Состав групп и органов там не задаётся: его дают данные.
-- * После правки проверьте страницу в каждом проекте через переключатель проекта:
-- у Goob другая схема тела, и ошибка может проявиться только там.
local p = {}
local getArgs = require('Module:Arguments').getArgs
local JsonPaths = require('Module:JsonPaths')
local lang = mw.getContentLanguage()
-- Моб, с которым сравниваются голод, жажда и запас воздуха.
local BASELINE_SPECIES = "Human"
-- Порядок групп урона при выводе (только порядок, состав групп — из данных бота).
local GROUP_ORDER = { "Brute", "Burn", "Toxin", "Airloss", "Genetic", "Metaphysical", "Electronic" }
-- Порядок органов при выводе (новая схема тела: ключи InitialBody.organs).
local ORGAN_ORDER = { "Brain", "Heart", "Lungs", "Stomach", "Liver", "Kidneys" }
---------------------------------------------------------------------------
-- Загрузка данных (mw.loadData кэширует страницу на всё время рендера)
---------------------------------------------------------------------------
local cache = {}
local function load(rel)
if cache[rel] == nil then
local ok, data = pcall(mw.loadData, JsonPaths.get(rel))
cache[rel] = (ok and type(data) == "table") and data or {}
end
return cache[rel]
end
-- Пустая ли таблица. next и # для таблиц из mw.loadData не работают
-- (это прокси только для чтения), поэтому проверяем через pairs.
local function isEmpty(t)
if type(t) ~= "table" then
return true
end
for _ in pairs(t) do
return false
end
return true
end
-- Запись прототипа/компонента из файла формата { default = {...}, id = { Id = {...} } }.
-- Возвращает функцию-геттер поля с подстановкой default или nil, если записи нет.
local function getEntry(rel, id)
if not id then
return nil
end
local data = load(rel)
local entry = (data.id or {})[id]
if entry == nil then
return nil
end
local default = data.default or {}
return function(field)
local v = entry[field]
-- бот иногда выгружает переопределение пустой таблицей — тогда берём default
if v == nil or (type(v) == "table" and isEmpty(v)) then
v = default[field]
end
return v
end
end
local function loc(key)
if not key then
return nil
end
local v = load("loc.json")[key]
if type(v) == "table" then
v = v._value
end
return v
end
local function ucfirst(s)
return s and lang:ucfirst(s) or s
end
local function list(t)
local out = {}
if type(t) == "table" then
for _, v in ipairs(t) do
out[#out + 1] = v
end
end
return out
end
---------------------------------------------------------------------------
-- Названия
---------------------------------------------------------------------------
local function entityName(id)
return ucfirst(loc("ent-" .. id)) or id
end
local function typeName(typeId)
local dt = getEntry("prototype/DamageType.json", typeId)
return ucfirst(loc(dt and dt("name"))) or typeId
end
local function reagentName(id)
local r = getEntry("prototype/Reagent.json", id)
return ucfirst(loc(r and r("name"))) or id
end
local function metabolizerName(id)
local m = getEntry("prototype/MetabolizerType.json", id)
return ucfirst(loc(m and m("name"))) or id
end
local function stageName(id)
local s = getEntry("prototype/MetabolismStage.json", id) or getEntry("prototype/MetabolismGroup.json", id)
return loc(s and s("name")) or id
end
---------------------------------------------------------------------------
-- Форматирование
---------------------------------------------------------------------------
-- Число с округлением до сотых и десятичной запятой.
local function num(x)
x = tonumber(x) or 0
local r = math.floor(math.abs(x) * 100 + 0.5) / 100
if x < 0 then
r = -r
end
return lang:formatNum(r)
end
-- Процент изменения урона: 1.3 → «+30%», 0.6 → «−40%».
local function percent(coef)
local d = math.floor((coef - 1) * 100 + 0.5)
if d > 0 then
return '<span class="negative">+' .. d .. '%</span>'
end
return '<span class="positive">−' .. (-d) .. '%</span>'
end
local function kelvinToC(k)
return num(k - 273.15) .. " °C"
end
-- Ключи числового словаря (пороги урона), отсортированные по возрастанию.
local function sortedNumKeys(t)
local keys = {}
for k in pairs(t or {}) do
if tonumber(k) then
keys[#keys + 1] = k
end
end
table.sort(keys, function(a, b) return tonumber(a) < tonumber(b) end)
return keys
end
-- Типы урона в порядке групп; возвращает список { id, value }.
local function orderedTypes(map)
local out, seen = {}, {}
for _, g in ipairs(GROUP_ORDER) do
local dg = getEntry("prototype/DamageGroup.json", g)
for _, t in ipairs(list(dg and dg("damageTypes"))) do
if map[t] ~= nil and not seen[t] then
out[#out + 1] = { t, map[t] }
seen[t] = true
end
end
end
-- типы, которых нет ни в одной известной группе
local rest = {}
for t, v in pairs(map) do
if not seen[t] then
rest[#rest + 1] = { t, v }
end
end
table.sort(rest, function(a, b) return a[1] < b[1] end)
for _, e in ipairs(rest) do
out[#out + 1] = e
end
return out
end
-- Цвет значений, которые совпадают с человеческими (нейтральный жёлтый).
-- Расцветка страницы:
-- зелёный (class="positive") — положительное: лечение, преимущества, баффы;
-- красный (class="negative") — отрицательное: получаемый урон, недостатки, дебаффы.
-- жёлтый (SAME_STYLE) — остальные числа (здоровье, скорость, температура и т. п.).
-- COMPARE_WITH_HUMAN = true включает другой режим: числа красятся по сравнению с человеком
-- (лучше — зелёный, хуже — красный, так же — SAME_STYLE). Сейчас выключен.
local COMPARE_WITH_HUMAN = false
-- Нейтральный цвет чисел.
local SAME_STYLE = "color:#e6c07b;font-weight:bold"
-- true, пока выводится сам человек: сравнивать его с собой незачем, значения без цвета.
local isBaseline = false
-- Подсветка значения в сравнении с человеком — единая расцветка страницы:
-- зелёный (class="positive") — лучше, чем у человека;
-- красный (class="negative") — хуже, чем у человека;
-- жёлтый (SAME_STYLE) — так же, как у человека;
-- без цвета — сравнивать не с чем.
-- При наведении на подсвеченное значение видно значение человека.
-- higherBetter: true — чем больше, тем лучше (скорость, здоровье), false — наоборот (урон).
-- fmt — как показать значение человека в подсказке (по умолчанию num).
local function mark(text, v, base, higherBetter, fmt)
if not COMPARE_WITH_HUMAN then
-- Обычное число (не урон и не лечение): нейтральный цвет, для красоты.
if type(v) == "number" then
return '<span style="' .. SAME_STYLE .. '">' .. text .. '</span>'
end
return text
end
if type(v) ~= "number" or type(base) ~= "number" then
return text
end
if isBaseline then
return text
end
if math.abs(v - base) < 1e-6 then
return '<span style="' .. SAME_STYLE .. '" title="Как у человека">' .. text .. '</span>'
end
local better = (v > base) == higherBetter
local title = base == 0 and "У человека нет" or ("У человека: " .. (fmt or num)(base))
return '<span class="' .. (better and "positive" or "negative") .. '" title="' .. title .. '">' .. text .. '</span>'
end
-- Подсветка целой фразы: good = true — зелёная, false — красная.
local function tone(text, good)
return '<span class="' .. (good and "positive" or "negative") .. '">' .. text .. '</span>'
end
-- DamageSpecifier ({ types = {...}, groups = {...} }) → «уколы 5, ушибы 2».
-- base — такой же DamageSpecifier человека для сравнения (тип, которого у человека нет,
-- считается равным 0); nil — без подсветки. higherBetter — как в mark().
local function damageText(spec, base, higherBetter)
if type(spec) ~= "table" then
return nil
end
local function value(v, b)
if base and COMPARE_WITH_HUMAN then
return mark(num(v), v, b or 0, higherBetter)
elseif base then
-- Без сравнения: урон, который получает раса, — красный, лечение — зелёный.
return tone(num(v), higherBetter)
end
return num(v)
end
local baseTypes = type(base) == "table" and base.types or {}
local baseGroups = type(base) == "table" and base.groups or {}
local parts = {}
for _, e in ipairs(orderedTypes(spec.types or {})) do
if e[2] ~= 0 then
parts[#parts + 1] = mw.ustring.lower(typeName(e[1])) .. " " .. value(e[2], baseTypes[e[1]])
end
end
for gid, v in pairs(spec.groups or {}) do
if v ~= 0 then
local dg = getEntry("prototype/DamageGroup.json", gid)
parts[#parts + 1] = mw.ustring.lower(loc(dg and dg("name")) or gid) .. " " .. value(v, baseGroups[gid])
end
end
if #parts == 0 then
return nil
end
return table.concat(parts, ", ")
end
-- Урон, который получает раса: подсветка относительно человека (меньше — лучше).
-- base == nil — у человека такого урона нет (считается 0).
local function takenText(spec, base)
return damageText(spec, type(base) == "table" and base or {}, false)
end
-- Сумма урона в DamageSpecifier (для проверок «есть ли урон вообще»).
local function damageSum(spec)
local s = 0
if type(spec) == "table" then
for _, v in pairs(spec.types or {}) do
s = s + math.abs(v)
end
for _, v in pairs(spec.groups or {}) do
s = s + math.abs(v)
end
end
return s
end
-- Отрицательные значения DamageSpecifier как лечение: «ушибы 0,05».
-- base — лечение человека для сравнения (больше — лучше); nil — у человека его нет.
local function healText(spec, base)
local function negOnly(sp)
local neg = { types = {}, groups = {} }
local any = false
if type(sp) == "table" then
for t, v in pairs(sp.types or {}) do
if v < 0 then
neg.types[t] = -v
any = true
end
end
for g, v in pairs(sp.groups or {}) do
if v < 0 then
neg.groups[g] = -v
any = true
end
end
end
return neg, any
end
local neg, any = negOnly(spec)
if not any then
return nil
end
return damageText(neg, (negOnly(base)), true)
end
-- Строка интервала: бот отдаёт TimeSpan как число секунд или «0:00:05».
local function seconds(v)
if type(v) == "number" then
return v
end
if type(v) == "string" then
local h, m, s = v:match("^(%d+):(%d+):([%d%.]+)$")
if h then
return tonumber(h) * 3600 + tonumber(m) * 60 + tonumber(s)
end
return tonumber((v:gsub("s$", "")))
end
return nil
end
---------------------------------------------------------------------------
-- Раса и её моб
---------------------------------------------------------------------------
local function species(id)
return getEntry("prototype/Species.json", id)
end
local function speciesMob(id)
local s = species(id)
return s and s("prototype")
end
local function isPlayable(id)
local s = species(id)
return s ~= nil and s("roundStart") == true
end
local function comp(name, mob)
return getEntry("component/" .. name .. ".json", mob)
end
---------------------------------------------------------------------------
-- Органы: новая схема (InitialBody + органы-сущности) и старая (Body prototype, Goob)
---------------------------------------------------------------------------
-- Возвращает список органов с метаболизмом:
-- { { id = "OrganHumanHeart", slot = "Heart", max = 2, types = {...}, stages = {...} }, ... }
local function organs(mob)
local out = {}
local function add(slot, organId)
local m = getEntry("component/Metabolizer.json", organId)
if not m then
return
end
local stages = {}
for _, st in ipairs(list(m("stages"))) do
stages[#stages + 1] = st
end
for _, g in ipairs(list(m("groups"))) do
stages[#stages + 1] = type(g) == "table" and g.id or g
end
out[#out + 1] = {
id = organId,
slot = slot,
max = m("maxReagents") or m("maxPoisonsProcessable"),
types = list(m("metabolizerTypes")),
stages = stages,
interval = seconds(m("updateInterval")),
}
end
local ib = comp("InitialBody", mob)
local initial = ib and ib("organs")
if not isEmpty(initial) then
local used = {}
for _, slot in ipairs(ORGAN_ORDER) do
if initial[slot] then
add(slot, initial[slot])
used[slot] = true
end
end
local rest = {}
for slot, organId in pairs(initial) do
if not used[slot] then
rest[#rest + 1] = slot
end
end
table.sort(rest)
for _, slot in ipairs(rest) do
add(slot, initial[slot])
end
return out
end
local body = comp("Body", mob)
local bp = getEntry("prototype/Body.json", body and body("prototype"))
if bp then
local slots = bp("slots") or {}
local slotNames = {}
for name in pairs(slots) do
slotNames[#slotNames + 1] = name
end
table.sort(slotNames)
-- органы из всех частей тела, в порядке ORGAN_ORDER (ключи в Body — строчными)
local found = {}
for _, name in ipairs(slotNames) do
for k, organId in pairs(slots[name].organs or {}) do
found[#found + 1] = { k, organId }
end
end
local rank = {}
for i, k in ipairs(ORGAN_ORDER) do
rank[mw.ustring.lower(k)] = i
end
table.sort(found, function(a, b)
local ra, rb = rank[a[1]] or 99, rank[b[1]] or 99
if ra ~= rb then
return ra < rb
end
return a[1] < b[1]
end)
for _, e in ipairs(found) do
add(e[1], e[2])
end
end
return out
end
-- Подходит ли условие эффекта реагента под типы метаболизма органа.
-- Понимает MetabolizerTypeCondition (новая схема) и OrganType (старая схема).
local function conditionsMatch(conditions, types)
local has = {}
for _, t in ipairs(types) do
has[t] = true
end
for _, c in ipairs(list(conditions)) do
for ctype, data in pairs(c) do
if ctype == "!type:MetabolizerTypeCondition" then
local hit = false
for _, t in ipairs(list(data.type)) do
if has[t] then
hit = true
end
end
if data.inverted then
hit = not hit
end
if not hit then
return false
end
elseif ctype == "!type:OrganType" then
local hit = has[data.type] == true
if data.shouldHave == false then
hit = not hit
end
if not hit then
return false
end
end
end
end
return true
end
-- Газы при вдохе. Возвращает два множества ID реагентов:
-- breath — эффект Oxygenate (без отрицательного множителя) подходит под тип лёгких;
-- harm — эффект HealthChange с уроном в группе дыхания (Respiration / Gas).
local gasCache = {}
local function gases(types)
local key = table.concat(types, ",")
if gasCache[key] then
return gasCache[key][1], gasCache[key][2]
end
local breath, harm = {}, {}
local reagents = load("prototype/Reagent.json").id or {}
for rid, r in pairs(reagents) do
local metabolisms = r.metabolisms
if type(metabolisms) == "table" then
local ok, bad = false, false
for group, entry in pairs(metabolisms) do
for _, eff in ipairs(list(type(entry) == "table" and entry.effects)) do
local ox = eff["!type:Oxygenate"]
if ox and (ox.factor == nil or ox.factor > 0) and conditionsMatch(ox.conditions, types) then
ok = true
end
local hc = eff["!type:HealthChange"]
if hc and (group == "Respiration" or group == "Gas") and conditionsMatch(hc.conditions, types) then
local d = hc.damage or {}
local sum = 0
for _, v in pairs(d.types or {}) do
sum = sum + v
end
for _, v in pairs(d.groups or {}) do
sum = sum + v
end
if sum > 0 then
bad = true
end
end
end
end
if ok then
breath[rid] = true
elseif bad then
harm[rid] = true
end
end
end
gasCache[key] = { breath, harm }
return breath, harm
end
-- Типы метаболизма лёгких из списка органов.
local function lungTypes(orgs)
for _, o in ipairs(orgs) do
for _, st in ipairs(o.stages) do
if st == "Respiration" or st == "Gas" then
return o.types
end
end
end
return nil
end
-- Названия реагентов из множества (кроме тех, что есть в except и except2), по алфавиту, строчными.
local function reagentList(set, except, except2)
local out = {}
for rid in pairs(set) do
if not (except and except[rid]) and not (except2 and except2[rid]) then
out[#out + 1] = mw.ustring.lower(reagentName(rid))
end
end
table.sort(out)
return out
end
-- Вещества, которые действуют на расу не так, как на человека.
-- Для каждого вещества складываются все эффекты HealthChange/EvenHealthChange,
-- которые сработают у расы и у человека (по условиям MetabolizerTypeCondition
-- и OrganType; пороги вроде «от 15 ед.» не учитываются). Итог за тик: меньше 0 — лечит,
-- больше 0 — вредит. Сравнение с человеком даёт четыре списка:
-- heal — расу лечит, а человека нет; harm — расе вредит, а человеку нет;
-- blocked — человека лечит, а расу нет; safe — человеку вредит, а расе нет.
local function specialReagents(types, baseTypes)
local function set(list_)
local t = {}
for _, v in ipairs(list_) do
t[v] = true
end
return t
end
local race, base = set(types), set(baseTypes)
-- Сработает ли эффект у тела с типами метаболизма has.
local function applies(conditions, has)
for _, c in ipairs(list(conditions)) do
for ctype, data in pairs(c) do
if ctype == "!type:MetabolizerTypeCondition" then
local hit = false
for _, t in ipairs(list(data.type)) do
if has[t] then
hit = true
end
end
if hit == (data.inverted == true) then
return false
end
elseif ctype == "!type:OrganType" then
if (has[data.type] == true) ~= (data.shouldHave ~= false) then
return false
end
end
end
end
return true
end
-- Упоминает ли эффект тип метаболизма (иначе он одинаков для всех рас).
local function typed(conditions)
for _, c in ipairs(list(conditions)) do
if c["!type:MetabolizerTypeCondition"] or c["!type:OrganType"] then
return true
end
end
return false
end
local function total(t)
local sum = 0
for _, v in pairs(t or {}) do
if type(v) == "number" then
sum = sum + v
end
end
return sum
end
local heal, harm, blocked, safe = {}, {}, {}, {}
for rid, r in pairs(load("prototype/Reagent.json").id or {}) do
if type(r.metabolisms) == "table" then
local raceSum, baseSum, special = 0, 0, false
for group, entry in pairs(r.metabolisms) do
if group ~= "Respiration" and group ~= "Gas" and type(entry) == "table" then
for _, eff in ipairs(list(entry.effects)) do
local amount, conditions
local hc, ehc = eff["!type:HealthChange"], eff["!type:EvenHealthChange"]
if hc then
local d = hc.damage or {}
amount, conditions = total(d.types) + total(d.groups), hc.conditions
elseif ehc then
amount, conditions = total(ehc.damage), ehc.conditions
end
if amount and amount ~= 0 and typed(conditions) then
special = true
if applies(conditions, race) then
raceSum = raceSum + amount
end
if applies(conditions, base) then
baseSum = baseSum + amount
end
end
end
end
end
if special then
if raceSum < 0 and baseSum >= 0 then
heal[rid] = true
elseif raceSum > 0 and baseSum <= 0 then
harm[rid] = true
elseif baseSum < 0 and raceSum >= 0 then
blocked[rid] = true
elseif baseSum > 0 and raceSum <= 0 then
safe[rid] = true
end
end
end
end
return heal, harm, blocked, safe
end
-- Типы метаболизма всех органов, кроме лёгких.
local function bodyTypes(orgs)
local seen, out = {}, {}
for _, o in ipairs(orgs) do
local lung = false
for _, st in ipairs(o.stages) do
if st == "Respiration" or st == "Gas" then
lung = true
end
end
if not lung then
for _, t in ipairs(o.types) do
if not seen[t] then
seen[t] = true
out[#out + 1] = t
end
end
end
end
return out
end
---------------------------------------------------------------------------
-- Голод и жажда: скорость относительно человека
---------------------------------------------------------------------------
-- Скорость убывания (положительное число) для Hunger/Thirst моба или nil.
local function satiationRate(mob, kind)
local sat = comp("Satiation", mob)
local sats = sat and sat("satiations")
if not isEmpty(sats) and sats[kind] then
local proto = getEntry("prototype/Satiation.json", sats[kind].prototype)
local rate = proto and proto("baseChangeRate")
return rate and math.abs(rate) or nil
end
local c = comp(kind, mob)
local rate = c and c("baseDecayRate")
return rate and math.abs(rate) or nil
end
local function rateCompare(rate, base)
if not rate or not base or base == 0 then
return nil
end
local ratio = rate / base
if math.abs(ratio - 1) < 0.01 then
return nil
end
if ratio > 1 then
return "в " .. num(ratio) .. " раза быстрее, чем у человека"
end
if ratio == 0 then
return "не убывает"
end
return "в " .. num(1 / ratio) .. " раза медленнее, чем у человека"
end
---------------------------------------------------------------------------
-- Блоки вывода
---------------------------------------------------------------------------
-- Карточка расы (Шаблон:Racebox).
local function raceboxInfo(mob)
local dmg = comp("Damageable", mob)
local setId = dmg and dmg("damageModifierSet")
local set = getEntry("prototype/DamageModifierSet.json", setId)
local plus, immune, flat = {}, {}, {}
if set then
for _, e in ipairs(orderedTypes(set("coefficients") or {})) do
if e[2] == 0 then
immune[#immune + 1] = ":" .. typeName(e[1])
elseif math.abs(e[2] - 1) > 0.001 then
plus[#plus + 1] = ":" .. percent(e[2]) .. " " .. typeName(e[1])
end
end
for _, e in ipairs(orderedTypes(set("flatReductions") or {})) do
if e[2] ~= 0 then
-- Плоское снижение: положительное — урона меньше (зелёный), отрицательное — больше (красный).
local cls = e[2] > 0 and "positive" or "negative"
local sign = e[2] > 0 and "−" or "+"
flat[#flat + 1] = ':<span class="' .. cls .. '">' .. sign .. num(math.abs(e[2])) .. "</span> "
.. typeName(e[1]) .. " с каждого удара"
end
end
end
local out = { '<div style="background-color:#27272e;padding-bottom: 3px">' }
if #plus == 0 and #immune == 0 and #flat == 0 then
out[#out + 1] = "<center>'''Получает:'''</center>\n<center>Не имеет модификаторов получаемого урона.</center>"
else
if #plus > 0 or #flat > 0 then
out[#out + 1] = "<center>'''Получает:'''</center>\n<p>\n" .. table.concat(plus, "\n")
.. (#flat > 0 and ((#plus > 0 and "\n" or "") .. table.concat(flat, "\n")) or "") .. "\n</p>"
end
if #immune > 0 then
if #plus > 0 or #flat > 0 then
out[#out + 1] = '<div style="border-bottom: 1px solid grey;"></div>'
end
out[#out + 1] = "<center>'''Не получает урона:'''</center>\n<p>\n" .. table.concat(immune, "\n") .. "\n</p>"
end
end
out[#out + 1] = "</div>"
return table.concat(out, "\n")
end
local function imageFor(mob)
-- Спрайты мобов бот загружает как «<ID>.png», для форков — «<Проект>-<ID>.png».
local name = JsonPaths.prefixFile(mob) .. ".png"
local t = mw.title.new("Файл:" .. name)
if t and t.file and t.file.exists then
return name
end
t = mw.title.new("Файл:" .. mob .. ".png")
if t and t.file and t.file.exists then
return mob .. ".png"
end
return nil
end
-- Пороги состояний моба: { PreCritical = 100, Critical = 125, Dead = 200 }.
local function thresholdsOf(mob)
local thr = mob and comp("MobThresholds", mob)
local th = thr and thr("thresholds")
local out = {}
if not isEmpty(th) then
for _, k in ipairs(sortedNumKeys(th)) do
if not out[th[k]] then
out[th[k]] = tonumber(k)
end
end
end
return out, th
end
-- Объём крови моба (новая и старая схема Bloodstream).
local function bloodOf(bs)
if not bs then
return nil, nil
end
local ref = bs("bloodReferenceSolution")
if type(ref) == "table" and not isEmpty(ref.reagents) then
local r = ref.reagents[1]
return r.ReagentId, r.Quantity
end
return bs("bloodReagent"), bs("bloodMaxVolume")
end
-- Список «Основная информация». Значения сравниваются с человеком (см. mark).
local function basicLines(id, mob)
local lines = {}
local baseMob = speciesMob(BASELINE_SPECIES)
local function bcomp(name)
return baseMob and comp(name, baseMob)
end
-- Пороги состояний: чем позже наступает состояние, тем лучше.
local base = thresholdsOf(baseMob)
local _, th = thresholdsOf(mob)
if not isEmpty(th) then
local parts = {}
local hasCrit = false
for _, k in ipairs(sortedNumKeys(th)) do
local state = th[k]
local v = mark(num(k), tonumber(k), base[state], true)
if state == "PreCritical" then
parts[#parts + 1] = "подкрит — с " .. v .. " урона"
elseif state == "Critical" then
parts[#parts + 1] = "критическое состояние — с " .. v .. " урона"
hasCrit = true
elseif state == "Dead" then
parts[#parts + 1] = "смерть — с " .. v .. " урона"
break
end
end
local line = "* '''Здоровье''': " .. table.concat(parts, ", ") .. "."
if not hasCrit then
line = line .. " " .. tone("Критического состояния нет.", false)
end
lines[#lines + 1] = line
end
-- Скорость: больше — лучше.
local ms = comp("MovementSpeedModifier", mob)
if ms then
local baseMs = bcomp("MovementSpeedModifier")
local walk, run = ms("baseWalkSpeed") or 0, ms("baseSprintSpeed") or 0
lines[#lines + 1] = "* '''Скорость передвижения''': шаг "
.. mark(num(walk), walk, baseMs and baseMs("baseWalkSpeed"), true)
.. ", бег " .. mark(num(run), run, baseMs and baseMs("baseSprintSpeed"), true) .. "."
local acc = ms("baseWeightlessAcceleration")
local baseAcc = baseMs and baseMs("baseWeightlessAcceleration")
if acc and baseAcc and baseAcc > 0 and math.abs(acc / baseAcc - 1) > 0.01 then
lines[#lines + 1] = "** Ускорение в невесомости: "
.. mark("×" .. num(acc / baseAcc), acc, baseAcc, true) .. " от человеческого."
end
end
-- Замедление от урона: множитель скорости, больше — лучше.
local slow = comp("SlowOnDamage", mob)
local st = slow and slow("speedModifierThresholds")
if not isEmpty(st) then
local baseSlow = bcomp("SlowOnDamage")
local bst = baseSlow and baseSlow("speedModifierThresholds") or {}
local parts = {}
for _, k in ipairs(sortedNumKeys(st)) do
-- Множитель скорости человека при том же уроне: последний его порог не выше k
-- (до первого порога человек не замедлен, множитель 1).
local b, bestKey = 1, nil
for bk, bv in pairs(bst) do
local n = tonumber(bk)
if n and n <= tonumber(k) and (not bestKey or n > bestKey) then
b, bestKey = bv, n
end
end
parts[#parts + 1] = "с " .. num(k) .. " урона — " .. mark("×" .. num(st[k]), st[k], b, true)
end
lines[#lines + 1] = "* '''Замедление от урона''': " .. table.concat(parts, ", ") .. "."
end
-- Кровь
local bs = comp("Bloodstream", mob)
local bbs = bcomp("Bloodstream")
local _, baseVolume = bloodOf(bbs)
if not bs then
lines[#lines + 1] = "* '''Крови нет'''."
else
local reagent, volume = bloodOf(bs)
local line = "* '''Кровь''': " .. (reagent and reagentName(reagent) or "?")
if volume == 0 then
line = "* '''Крови нет''' (объём крови 0)"
elseif volume then
line = line .. ", " .. mark(num(volume), volume, baseVolume, true) .. " ед."
end
lines[#lines + 1] = line
local loss = takenText(bs("bloodlossDamage"), bbs and bbs("bloodlossDamage"))
if loss then
lines[#lines + 1] = "** При нехватке крови получает урон (" .. loss .. ") за цикл кровотока"
.. (seconds(bs("updateInterval")) and (" (раз в " .. num(seconds(bs("updateInterval"))) .. " с)") or "") .. "."
end
if bs("maxBleedAmount") == 0 then
lines[#lines + 1] = "** " .. tone("Не истекает кровью.", true)
elseif bs("bleedReductionAmount") == 0 then
lines[#lines + 1] = "** " .. tone("Кровотечение само не останавливается.", false)
end
if bs("bloodRefreshAmount") == 0 and volume ~= 0 then
lines[#lines + 1] = "** " .. tone("Кровь сама не восстанавливается.", false)
end
end
-- Голод и жажда: медленнее — лучше.
for _, kind in ipairs({ "Hunger", "Thirst" }) do
local rate = satiationRate(mob, kind)
local baseRate = satiationRate(baseMob, kind)
local label = kind == "Hunger" and "Голод" or "Жажда"
if rate == nil then
if baseMob ~= mob and baseRate then
lines[#lines + 1] = "* '''" .. label .. "''': " .. tone("не испытывает", true) .. "."
end
else
local cmp = rateCompare(rate, baseRate)
if cmp then
lines[#lines + 1] = "* '''" .. label .. "''' нарастает " .. tone(cmp, rate < baseRate) .. "."
end
end
end
-- Естественная регенерация: больше — лучше.
local pd = comp("PassiveDamage", mob)
local bpd = bcomp("PassiveDamage")
local heal = pd and healText(pd("damage"), bpd and bpd("damage"))
if heal then
local line = "* '''Естественная регенерация''': " .. heal .. " в секунду"
local cap = pd("damageCap")
if cap and cap > 0 then
line = line .. ", пока общий урон не больше " .. mark(num(cap), cap, bpd and bpd("damageCap"), true)
end
lines[#lines + 1] = line .. "."
elseif bpd and healText(bpd("damage")) then
lines[#lines + 1] = "* '''Естественная регенерация''': " .. tone("нет", false) .. "."
end
return lines
end
-- Список «Окружающая среда».
local function environmentLines(mob, orgs)
local lines = {}
local baseMob = speciesMob(BASELINE_SPECIES)
local function bcomp(name)
return baseMob and comp(name, baseMob)
end
local baseResp = bcomp("Respirator")
-- Дыхание
local resp = comp("Respirator", mob)
if not resp then
lines[#lines + 1] = "* " .. tone("'''Не дышит''' и не задыхается.", true)
else
local types = lungTypes(orgs)
if types then
local breath, harm = gases(types)
local b = reagentList(breath)
if #b > 0 then
lines[#lines + 1] = "* '''Дышит''': " .. table.concat(b, ", ") .. "."
end
-- вредные газы показываем только в сравнении с человеком
local baseTypes = lungTypes(organs(baseMob))
if baseTypes then
local _, baseHarm = gases(baseTypes)
local extra = reagentList(harm, baseHarm)
if #extra > 0 then
lines[#lines + 1] = "** В отличие от человека, " .. tone("вредны", false) .. ": " .. table.concat(extra, ", ") .. "."
end
local safe = reagentList(baseHarm, harm, breath)
if #safe > 0 then
lines[#lines + 1] = "** В отличие от человека, " .. tone("не вредны", true) .. ": " .. table.concat(safe, ", ") .. "."
end
end
end
local dmg = takenText(resp("damage"), baseResp and baseResp("damage"))
local rec = healText(resp("damageRecovery"), baseResp and baseResp("damageRecovery"))
local interval = seconds(resp("updateInterval"))
if dmg then
lines[#lines + 1] = "* '''Удушье''': без воздуха получает урон (" .. dmg
.. ") за вдох" .. (interval and (" (раз в " .. num(interval) .. " с)") or "")
.. (rec and (", при дыхании восстанавливается (" .. rec .. ")") or "") .. "."
end
local sat = resp("maxSaturation")
local baseSat = baseResp and baseResp("maxSaturation")
if sat and baseSat and baseSat > 0 and math.abs(sat / baseSat - 1) > 0.01 then
lines[#lines + 1] = "** Запас воздуха: " .. mark("×" .. num(sat / baseSat), sat, baseSat, true) .. " от человеческого."
end
end
-- Температура (новая схема: TemperatureDamage, Goob: Temperature).
-- Нижний порог: ниже — лучше; верхний: выше — лучше; урон: меньше — лучше.
local t = comp("TemperatureDamage", mob) or comp("Temperature", mob)
local bt = bcomp("TemperatureDamage") or bcomp("Temperature")
local function bget(field)
return bt and bt(field)
end
if t and t("heatDamageThreshold") then
local cold, heat = t("coldDamageThreshold"), t("heatDamageThreshold")
local coldDmg = takenText(t("coldDamage"), bget("coldDamage"))
local heatDmg = takenText(t("heatDamage"), bget("heatDamage"))
local heatText = mark(kelvinToC(heat), heat, bget("heatDamageThreshold"), true, kelvinToC)
if cold and cold > 0 and coldDmg then
lines[#lines + 1] = "* '''Безопасная температура''': от "
.. mark(kelvinToC(cold), cold, bget("coldDamageThreshold"), false, kelvinToC) .. " до " .. heatText .. "."
lines[#lines + 1] = "** Ниже — " .. coldDmg .. " в секунду."
else
lines[#lines + 1] = "* '''Безопасная температура''': до " .. heatText .. ", " .. tone("холода не боится", true) .. "."
end
if heatDmg then
lines[#lines + 1] = "** Выше — " .. heatDmg .. " в секунду."
end
end
-- Давление: урон меньше — лучше.
local baro = comp("Barotrauma", mob)
local bbaro = bcomp("Barotrauma")
if not baro or damageSum(baro("damage")) == 0 then
lines[#lines + 1] = "* '''Давление''': " .. tone("урона от низкого и высокого давления не получает", true) .. "."
else
lines[#lines + 1] = "* '''Давление''': без защиты в вакууме и при высоком давлении получает в секунду до: "
.. takenText(baro("damage"), bbaro and bbaro("damage")) .. " (зависит от давления)."
end
return lines
end
-- Список «Боевые характеристики». Урон и скорость атаки: больше — лучше.
local function combatLines(mob)
local lines = {}
local baseMob = speciesMob(BASELINE_SPECIES)
local bmw = baseMob and comp("MeleeWeapon", baseMob)
local mw_ = comp("MeleeWeapon", mob)
-- Типы урона у рас разные (у человека ушибы, у вокса порезы), поэтому сравниваем сумму.
local dmg = mw_ and damageText(mw_("damage"))
if dmg then
dmg = mark(dmg, damageSum(mw_("damage")), bmw and damageSum(bmw("damage")), true)
local line = "* '''Безоружная атака''': " .. dmg .. "."
local rate = mw_("attackRate")
local baseRate = bmw and bmw("attackRate") or 1
if rate and math.abs(rate - 1) > 0.001 then
line = line .. " Скорость атаки: " .. mark(num(rate), rate, baseRate, true) .. " удара в секунду."
end
lines[#lines + 1] = line
end
return lines
end
-- Название органа. Если у органа нет своего названия (например, у органов дворфа
-- в Corvax в локализации стоит общее «орган»), берём название базового органа слота.
local function organName(o)
local own = loc("ent-" .. o.id)
local generic = loc("ent-OrganBase")
if own and own ~= generic then
return ucfirst(own)
end
local base = o.slot and loc("ent-OrganBase" .. lang:ucfirst(o.slot))
return ucfirst(base or own) or o.id
end
-- Не больше limit элементов списка, остальное — «и ещё N».
local function capped(items, limit)
if #items <= limit then
return table.concat(items, ", ")
end
local head = {}
for i = 1, limit do
head[i] = items[i]
end
return table.concat(head, ", ") .. " и ещё " .. (#items - limit)
end
-- Список «Особые вещества».
local function reagentLines(orgs)
local lines = {}
local types = bodyTypes(orgs)
if #types == 0 then
return lines
end
-- Типы метаболизма человека, с которым идёт сравнение.
local baseMob = speciesMob(BASELINE_SPECIES)
local baseTypes = baseMob and bodyTypes(organs(baseMob)) or {}
local heal, harm, blocked, safe = specialReagents(types, baseTypes)
local h, x, b, n = reagentList(heal), reagentList(harm), reagentList(blocked), reagentList(safe)
if #h > 0 then
lines[#lines + 1] = "* " .. tone("'''Лечат'''", true) .. ": " .. capped(h, 20) .. "."
end
if #x > 0 then
lines[#lines + 1] = "* " .. tone("'''Вредят'''", false) .. ": " .. capped(x, 20) .. "."
end
if #b > 0 then
lines[#lines + 1] = "* " .. tone("'''Не лечат'''", false) .. " (человека лечат): " .. capped(b, 20) .. "."
end
if #n > 0 then
lines[#lines + 1] = "* " .. tone("'''Не вредят'''", true) .. " (человеку вредят): " .. capped(n, 20) .. "."
end
return lines
end
-- Список «Внутренние органы».
local function organLines(orgs)
local lines = {}
for _, o in ipairs(orgs) do
local stages = {}
for _, st in ipairs(o.stages) do
stages[#stages + 1] = mw.ustring.lower(stageName(st))
end
local types = {}
for _, t in ipairs(o.types) do
types[#types + 1] = metabolizerName(t)
end
local line = "* '''" .. organName(o) .. "'''"
if #stages > 0 then
line = line .. " (" .. table.concat(stages, ", ") .. ")"
end
local details = {}
if o.max and not (#o.stages == 1 and (o.stages[1] == "Respiration" or o.stages[1] == "Gas")) then
details[#details + 1] = "одновременно обрабатывает веществ: " .. num(o.max)
end
if #types > 0 then
details[#details + 1] = "тип метаболизма: " .. table.concat(types, ", ")
end
if o.interval and o.interval ~= 1 then
details[#details + 1] = "срабатывает раз в " .. num(o.interval) .. " с"
end
if #details > 0 then
line = line .. " — " .. table.concat(details, "; ")
end
lines[#lines + 1] = line .. "."
end
return lines
end
-- Список «Прочее».
local function otherLines(mob)
local lines = {}
local b = comp("Butcherable", mob)
local spawned = b and b("spawned")
if not isEmpty(spawned) then
local parts = {}
for _, s in ipairs(list(spawned)) do
parts[#parts + 1] = entityName(s.id) .. " × " .. num(s.amount or 1)
end
lines[#lines + 1] = "* '''Разделка''': " .. table.concat(parts, ", ") .. "."
end
return lines
end
---------------------------------------------------------------------------
-- Особенности: компоненты моба расы → строка описания.
-- Текст здесь — только описание механики, все числа берутся из данных компонента.
-- Порядок в списке = порядок вывода. Функция получает геттер полей компонента
-- и возвращает текст (без «* ») или nil, если выводить нечего.
---------------------------------------------------------------------------
local function pct(x)
return num((x or 0) * 100) .. "%"
end
local function times(x)
return "в " .. num(x) .. " раза"
end
local function actionName(id)
return id and ("«" .. entityName(id) .. "»") or ""
end
-- Особенности рас по компонентам моба.
-- Каждая строка: { "ИмяКомпонента", функция(c, mob) → текст или nil }.
-- * ИмяКомпонента — как в прототипе без суффикса Component (файл бота component/<Имя>.json).
-- * c("поле") возвращает значение поля компонента с учётом значений по умолчанию.
-- * Вернуть nil — строка не выводится (например, если значение не отличается от человека).
-- * Числа берите из c(...), а не пишите в текст: тогда они обновятся вместе с игрой.
-- Строки выводятся в том порядке, в каком стоят в таблице.
-- Пример:
-- { "NoSlip", function() return "Не поскальзываются." end },
local FEATURES = {
{ "Pacified", function() return "Пацифисты: не могут атаковать других напрямую." end },
{ "Sericulture", function(c)
return "Производят паутинный шёлк, расходуя сытость (" .. num(c("productionLength") or 0) .. " с на моток)."
end },
{ "JumpAbility", function(c)
return "Умеют прыгать на " .. num(c("jumpDistance") or 0) .. " клетки."
end },
{ "FlyAbility", function() return "Умеют ненадолго взлетать, перелетая препятствия и пропасти, за счёт выносливости." end },
{ "DashAction", function(c) return "Могут совершить рывок (способность " .. actionName(c("actionProto")) .. ")." end },
{ "AgillitySkill", function() return "Могут включить ловкость: перепрыгивают столы и бегут быстрее, тратя выносливость." end },
{ "Rootable", function() return "Могут укорениться: стоят на месте и впитывают вещества из лужи под ногами." end },
{ "GibAction", function(c) return "После смерти доступна способность " .. actionName(c("actionPrototype")) .. "." end },
{ "IgniteOnHeatDamage", function(c)
return "Вспыхивают, если за раз получают больше " .. num(c("threshold") or 0) .. " урона от жара."
end },
{ "IgnoreKudzu", function() return "Кудзу их не замедляет." end },
{ "SpeedModifierImmunity", function() return "Эффекты замедления на них не действуют." end },
{ "NoSlip", function() return "Не поскальзываются." end },
{ "HardSlip", function(c)
local d = takenText(c("fallDamage"))
return "Падая при поскальзывании, получают урон" .. (d and (" (" .. d .. ")") or "") .. "."
end },
{ "NaturalNightVision", function() return "Видят в темноте." end },
{ "NightVisionEye", function() return "Видят в темноте." end },
{ "Photophobia", function(c)
return "На свету замедляются (×" .. num(c("speedModifier") or 1) .. ")."
end },
{ "DamagedByFlashing", function(c)
local d = takenText(c("flashDamage"))
return "Вспышки наносят им урон" .. (d and (" (" .. d .. ")") or "") .. "."
end },
{ "FlashImmunity", function() return "Не ослепляются вспышками." end },
{ "FlashModifier", function(c) return "Вспышки действуют на них " .. times(c("modifier") or 1) .. " дольше." end },
{ "EyeProtection", function() return "Глаза защищены от яркого света сварки." end },
{ "ChatModifier", function(c)
return "Слышат шёпот на расстоянии " .. num(c("whisperListeningRange") or 0) .. " клеток."
end },
{ "ListenUpSkill", function() return "Могут прислушаться: пока стоят на месте, слышат существ за стенами." end },
{ "InnateMiningScannerViewer", function(c)
return "Видят руду в радиусе " .. num(c("viewRange") or 0) .. " клеток без шахтёрского сканера."
end },
{ "ScentTracker", function() return "Могут выслеживать по запаху." end },
{ "SolutionScanner", function() return "Видят состав жидкостей, как химический анализатор." end },
{ "Wagging", function() return "Могут вилять хвостом." end },
{ "PseudoItem", function() return "Помещаются в сумки и рюкзаки." end },
{ "CrawlUnderObjects", function() return "Могут пролезать под столами." end },
{ "MouthStorage", function() return "Прячут мелкие предметы за щеками; от удара роняют их." end },
{ "Rummager", function() return "Могут рыться в мусорках." end },
{ "MobCallSource", function(c)
return "Могут призывать мышей в радиусе " .. num(c("range") or 0) .. " клеток."
end },
{ "AlwaysTriggerMousetrap", function() return "Мышеловки срабатывают на них даже в обуви." end },
{ "NoShoesSilentFootsteps", function() return "Без обуви ходят бесшумно." end },
{ "MessyDrinker", function(c)
return "Пьют неаккуратно: с вероятностью " .. pct(c("spillChance")) .. " проливают напиток."
end },
{ "LightweightDrunk", function(c)
return "Алкоголь действует на них слабее (×" .. num(c("boozeStrengthMultiplier") or 1) .. ")."
end },
{ "BonusMeleeDamage", function(c)
local set = c("damageModifierSet")
local parts = {}
for _, e in ipairs(orderedTypes(type(set) == "table" and set.coefficients or {})) do
parts[#parts + 1] = mw.ustring.lower(typeName(e[1])) .. " " .. percent(e[2])
end
if #parts == 0 then
return nil
end
return "Бьют в ближнем бою сильнее: " .. table.concat(parts, ", ") .. "."
end },
{ "BonusStaminaDamage", function(c)
return "Урон по выносливости в ближнем бою ×" .. num(c("multiplier") or 1) .. "."
end },
{ "PlayerToolModifier", function(c)
local m = c("pryTimeMultiplier")
return m and m > 0 and ("Вскрывают двери " .. times(1 / m) .. " быстрее.") or nil
end },
{ "Prying", function() return "Могут вскрывать двери голыми руками." end },
{ "StripModifiers", function(c)
local m = c("stripTimeMultiplier")
return m and m > 0 and ("Снимают вещи с других " .. times(1 / m) .. " быстрее.") or nil
end },
{ "WeaponsUseInability", function() return "Плохо обращаются с крупным оружием." end },
{ "DamageOnHit", function(c)
local d = takenText(c("damage"))
return d and ("Сами получают урон при каждом ударе (" .. d .. ").") or nil
end },
{ "DamageOnLand", function(c)
local d = takenText(c("damage"))
return d and ("Получают урон, когда их бросают (" .. d .. ").") or nil
end },
{ "DamageOnAction", function(c)
local h = healText(c("damage"))
return h and ("Способность мгновенной регенерации: восстанавливает " .. h .. ".") or nil
end },
{ "ExplosionResistance", function(c)
return "Урон от взрывов ×" .. num(c("damageCoefficient") or 1) .. "."
end },
{ "Reflect", function(c)
return "С вероятностью " .. pct(c("reflectProb")) .. " отражают снаряды."
end },
{ "BlockInjection", function() return "Шприцы их не прокалывают, нужен гипоспрей." end },
{ "ItemCougher", function(c) return "Могут откашлять " .. actionName(c("item")) .. "." end },
{ "PassiveHeatGeneration", function() return "Тело постоянно само нагревается." end },
{ "FeroxiDehydrate", function(c)
return "При жажде ниже " .. num(c("dehydrationThreshold") or 0) .. " обезвоживаются, и метаболизм меняется."
end },
{ "SpawnGasOnGib", function() return "При разрыве тела выделяют газ." end },
{ "HarpySinger", function() return "Могут петь; урон прерывает пение." end },
{ "Instrument", function() return "На них можно сыграть как на музыкальном инструменте." end },
{ "MindLink", function() return "Имеют общий мысленный канал связи." end },
{ "IntrinsicRadioTransmitter", function() return "Имеют встроенную рацию." end },
{ "PowerCellDraw", function() return "Работают от энергоячейки: следите за зарядом." end },
{ "Silicon", function() return "Работают от внутренней батареи; при низком заряде замедляются." end },
{ "BatteryDrinker", function() return "Подзаряжаются от ЛКП и зарядников." end },
{ "KillOnOverheat", function() return "Погибают при перегреве." end },
{ "Repairable", function(c)
local h = healText(c("damage"))
return "Чинятся сваркой" .. (h and (": " .. h .. " за " .. num(seconds(c("doAfterDelay")) or 0) .. " с") or "") .. "."
end },
{ "TransferMindOnGib", function() return "Если тело разорвёт, сознание сохраняется в останках." end },
{ "ZombieImmune", function() return "Невосприимчивы к зомби-вирусу." end },
{ "Uncloneable", function() return "Не могут быть клонированы." end },
-- акценты: достаточно одного упоминания
{ "LizardAccent", "accent" }, { "MothAccent", "accent" }, { "GrowlingAccent", "accent" },
{ "OwOAccent", "accent" }, { "SkeletonAccent", "accent" }, { "CheeseAccent", "accent" },
{ "ResomiAccent", "accent" }, { "GolemAccent", "accent" }, { "CischiAccent", "accent" },
{ "ReplacementAccent", "accent" },
{ "DamagedSiliconAccent", function() return "При повреждениях речь искажается." end },
}
-- Горение относительно человека (Flammable.damage).
local function flammableLine(mob)
local f = comp("Flammable", mob)
local base = comp("Flammable", speciesMob(BASELINE_SPECIES))
if not f or not base then
return nil
end
local a, b = damageSum(f("damage")), damageSum(base("damage"))
if b == 0 or math.abs(a / b - 1) < 0.01 then
return nil
end
if a == 0 then
return "Огонь не наносит им урона."
end
return "Горят " .. (a > b and "сильнее" or "слабее") .. ", чем человек: " .. takenText(f("damage"), base("damage")) .. " в секунду при горении."
end
local function featureLines(mob)
local lines, accent = {}, false
for _, f in ipairs(FEATURES) do
local c = comp(f[1], mob)
if c then
if f[2] == "accent" then
if not accent then
lines[#lines + 1] = "* Говорят с особым акцентом."
accent = true
end
else
local text = f[2](c)
if text then
lines[#lines + 1] = "* " .. text
end
end
end
end
local fl = flammableLine(mob)
if fl then
lines[#lines + 1] = "* " .. fl
end
return lines
end
---------------------------------------------------------------------------
-- Функции для страницы
---------------------------------------------------------------------------
local function argId(frame)
local args = getArgs(frame)
return mw.text.trim(args[1] or ""), args
end
-- «1», если раса доступна при создании персонажа. С any=1 — если раса вообще есть в проекте.
function p.has(frame)
local id, args = argId(frame)
if args.any and args.any ~= "" then
return species(id) and "1" or ""
end
return isPlayable(id) and "1" or ""
end
function p.name(frame)
local id = argId(frame)
local s = species(id)
return ucfirst(loc(s and s("name"))) or id
end
function p.racebox(frame)
local id, args = argId(frame)
local mob = speciesMob(id)
if not mob then
return '<span class="error">Раса «' .. id .. '» не найдена в данных проекта.</span>'
end
local s = species(id)
local file = args.file or imageFor(mob)
local fileText = file and ("[[Файл:" .. file .. "|160px|link=" .. (args.link or "") .. "]]") or ""
return frame:expandTemplate {
title = "Racebox",
args = {
Name = args.name or ucfirst(loc(s("name"))) or id,
File = fileText,
Info = raceboxInfo(mob),
}
}
end
-- Подзаголовок раздела внутри блока расы. Это не заголовок вики (== ==),
-- а жирная строка: так в оглавление попадают только сами расы.
-- level= (например «===») возвращает обычные заголовки, если они нужны.
local function subHeading(title, level)
if level and level ~= "" then
return level .. " " .. title .. " " .. level
end
return '<div style="font-weight:bold;font-size:1.15em;margin:0.8em 0 0.3em;'
.. 'border-bottom:1px solid #3d3d4a;padding-bottom:2px">' .. title .. '</div>'
end
-- Разделы, которые по умолчанию свёрнуты (раскрываются по клику «показать»).
-- Сейчас пусто: раса и так лежит в шторке, вложенные шторки не нужны.
-- Пример: { ["Внутренние органы"] = true }.
local COLLAPSED = {}
function p.stats(frame)
local id, args = argId(frame)
isBaseline = (id == BASELINE_SPECIES)
local mob = speciesMob(id)
if not mob then
return ""
end
local orgs = organs(mob)
local out = {}
local function section(title, lines)
if #lines == 0 then
return
end
if COLLAPSED[title] and not args.level then
-- Встроенное сворачивание MediaWiki: заголовок виден всегда, список — по клику.
out[#out + 1] = '<div class="mw-collapsible mw-collapsed" data-expandtext="показать" data-collapsetext="скрыть">'
.. subHeading(title)
out[#out + 1] = '<div class="mw-collapsible-content">'
out[#out + 1] = table.concat(lines, "\n")
out[#out + 1] = '</div></div>'
else
out[#out + 1] = subHeading(title, args.level)
out[#out + 1] = table.concat(lines, "\n")
end
end
section("Основная информация", basicLines(id, mob))
section("Окружающая среда", environmentLines(mob, orgs))
section("Боевые характеристики", combatLines(mob))
section("Внутренние органы", organLines(orgs))
section("Особые вещества", reagentLines(orgs))
section("Прочее", otherLines(mob))
return table.concat(out, "\n")
end
-- Список особенностей расы по её компонентам.
-- notes= — строки, написанные вручную (идут первыми); header= — подзаголовок раздела,
-- который выводится, только если есть хоть одна строка.
function p.features(frame)
local id, args = argId(frame)
isBaseline = (id == BASELINE_SPECIES)
local mob = speciesMob(id)
if not mob then
return ""
end
local lines = {}
for line in mw.text.gsplit(args.notes or "", "\n", true) do
if mw.text.trim(line) ~= "" then
lines[#lines + 1] = line
end
end
for _, line in ipairs(featureLines(mob)) do
lines[#lines + 1] = line
end
if #lines == 0 then
return ""
end
local text = table.concat(lines, "\n")
if args.header and args.header ~= "" then
text = subHeading(args.header, args.level) .. "\n" .. text
end
return text
end
-- Разбор списка рас для nav и summary: «Human:Люди, Skeleton*:Скелеты, …».
-- ID — id прототипа расы, после двоеточия — заголовок её раздела на странице (на него
-- ведёт ссылка). Звёздочка после ID — показывать, даже если раса недоступна при создании
-- персонажа. Расы, которых нет в проекте, пропускаются.
local function raceList(text)
local out = {}
for part in mw.text.gsplit(text or "", ",", true) do
local idPart, title = part:match("^%s*([^:]+):(.-)%s*$")
if idPart then
local any = idPart:sub(-1) == "*"
local id = mw.text.trim(any and idPart:sub(1, -2) or idPart)
if (any and species(id)) or isPlayable(id) then
out[#out + 1] = { id = id, title = mw.text.trim(title) }
end
end
end
return out
end
-- Сетка иконок рас со ссылками на их разделы:
-- {{#invoke:SpeciesStats|nav|Human:Люди, Dwarf:Дворфы, …}}
function p.nav(frame)
local _, args = argId(frame)
local cards = {}
for _, r in ipairs(raceList(args[1])) do
local mob = speciesMob(r.id)
local file = mob and imageFor(mob)
cards[#cards + 1] = '<div style="width:112px;font-size:0.9em;text-align:center;background:#27272e;'
.. 'border:1px solid #3d3d4a;border-radius:6px;padding:4px">'
.. (file and ("[[Файл:" .. file .. "|64px|link=#" .. r.title .. "]]<br>") or "")
.. "[[#" .. r.title .. "|" .. r.title .. "]]</div>"
end
if #cards == 0 then
return ""
end
return '<div style="display:flex;flex-wrap:wrap;gap:8px;margin:0.5em 0 1em">'
.. table.concat(cards) .. '</div>'
end
-- Сводная таблица рас (сортируется по клику на заголовок столбца):
-- {{#invoke:SpeciesStats|summary|Human:Люди, Dwarf:Дворфы, …}}
-- Цвета — как на всей странице: сравнение с человеком.
function p.summary(frame)
local _, args = argId(frame)
local baseMob = speciesMob(BASELINE_SPECIES)
local function bc(name)
return baseMob and comp(name, baseMob)
end
local baseTh = thresholdsOf(baseMob)
local baseMs = bc("MovementSpeedModifier")
local _, baseVol = bloodOf(bc("Bloodstream"))
local bt = bc("TemperatureDamage") or bc("Temperature")
local rows = {
'{| class="wikitable sortable" style="text-align:center"',
"! Раса !! Крит !! Смерть !! Шаг !! Бег !! Кровь, ед. !! Дышит !! Темп. от !! Темп. до",
}
local function cell(sortValue, text)
if sortValue == nil then
return '| data-sort-value="-9999" | —'
end
return '| data-sort-value="' .. sortValue .. '" | ' .. text
end
for _, r in ipairs(raceList(args[1])) do
local mob = speciesMob(r.id)
if mob then
isBaseline = (r.id == BASELINE_SPECIES)
local th = thresholdsOf(mob)
local ms = comp("MovementSpeedModifier", mob)
local walk, run = ms and ms("baseWalkSpeed"), ms and ms("baseSprintSpeed")
local bsComp = comp("Bloodstream", mob)
local _, vol = bloodOf(bsComp)
if not bsComp then
vol = 0
end
local t = comp("TemperatureDamage", mob) or comp("Temperature", mob)
local cold = t and t("coldDamageThreshold")
local heat = t and t("heatDamageThreshold")
if cold == 0 then
cold = nil
end
local breathText = "не дышит"
if comp("Respirator", mob) then
local types = lungTypes(organs(mob))
local b = types and reagentList((gases(types))) or {}
breathText = #b > 0 and table.concat(b, ", ") or "—"
end
rows[#rows + 1] = "|-"
rows[#rows + 1] = "| style=\"text-align:left\" | [[#" .. r.title .. "|" .. r.title .. "]]"
rows[#rows + 1] = cell(th.Critical, th.Critical and mark(num(th.Critical), th.Critical, baseTh.Critical, true))
rows[#rows + 1] = cell(th.Dead, th.Dead and mark(num(th.Dead), th.Dead, baseTh.Dead, true))
rows[#rows + 1] = cell(walk, walk and mark(num(walk), walk, baseMs and baseMs("baseWalkSpeed"), true))
rows[#rows + 1] = cell(run, run and mark(num(run), run, baseMs and baseMs("baseSprintSpeed"), true))
rows[#rows + 1] = cell(vol, vol and (vol == 0 and "нет" or mark(num(vol), vol, baseVol, true)))
rows[#rows + 1] = "| " .. breathText
rows[#rows + 1] = cell(cold, cold and mark(kelvinToC(cold), cold, bt and bt("coldDamageThreshold"), false, kelvinToC))
rows[#rows + 1] = cell(heat, heat and mark(kelvinToC(heat), heat, bt and bt("heatDamageThreshold"), true, kelvinToC))
end
end
isBaseline = false
rows[#rows + 1] = "|}"
return table.concat(rows, "\n")
end
-- Имя файла картинки расы (спрайт бота) или file=, если задан; пусто, если картинки нет.
-- {{#invoke:SpeciesStats|image|Arachnid|file=Арахнид.png}}
function p.image(frame)
local id, args = argId(frame)
if args.file and args.file ~= "" then
return args.file
end
local mob = speciesMob(id)
return mob and imageFor(mob) or ""
end
-- Значение поля компонента моба расы, например
-- {{#invoke:SpeciesStats|value|Arachnid|Sericulture|hungerCost}}.
function p.value(frame)
local id, args = argId(frame)
local c = comp(mw.text.trim(args[2] or ""), speciesMob(id))
local v = c and c(mw.text.trim(args[3] or ""))
if type(v) == "number" then
return num(v)
end
return type(v) == "string" and v or ""
end
-- Расы проекта (roundStart), которых нет в переданном списке ID.
function p.missing(frame)
local _, args = argId(frame)
local known = {}
for part in mw.text.gsplit(args[1] or "", ",", true) do
known[mw.text.trim(part)] = true
end
local miss = {}
for sid in pairs(load("prototype/Species.json").id or {}) do
if isPlayable(sid) and not known[sid] then
miss[#miss + 1] = sid
end
end
table.sort(miss)
if #miss == 0 then
return ""
end
local names = {}
for _, sid in ipairs(miss) do
local s = species(sid)
names[#names + 1] = (ucfirst(loc(s and s("name"))) or sid) .. " (<code>" .. sid .. "</code>)"
end
return table.concat(names, ", ")
end
return p