Модуль:SpeciesStats
Материал из Space Station 14 Вики
Для документации этого модуля может быть создана страница Модуль: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, ...}} — расы проекта, которых нет в списке
--
-- КАК ПРАВИТЬ
-- * Цифры, названия и списки здесь не хранятся: они берутся из выгрузки бота и
-- обновляются сами. Если на странице устаревшая цифра, обычно нужно обновить
-- выгрузку бота, а не править модуль.
-- * Названия берутся из loc.json (ru-RU локализация репозитория). Если вместо
-- названия видно ID или английский текст, перевода нет в выгрузке.
-- * Текст на страницу выводят функции *Lines (basicLines, environmentLines,
-- combatLines, organLines, reagentLines, otherLines). Чтобы поменять формулировку
-- строки, найдите её текст поиском и правьте только текст в кавычках.
-- * Особенности, которые видно по компонентам моба, перечислены в таблице FEATURES.
-- Чтобы добавить новую, допишите туда строку по образцу (см. комментарий у таблицы).
-- * Особенность, которой нет в данных, пишется не здесь, а на самой странице
-- в параметре notes= у нужной расы.
-- * Порядок групп урона и органов задают 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
-- DamageSpecifier ({ types = {...}, groups = {...} }) → «уколы 5, ушибы 2».
-- cls — класс подсветки чисел: "negative" (красный, урон расе), "positive" (зелёный,
-- лечение) или nil (без подсветки, например урон, который раса наносит сама).
local function damageText(spec, cls)
if type(spec) ~= "table" then
return nil
end
local function value(v)
if cls then
return '<span class="' .. cls .. '">' .. num(v) .. '</span>'
end
return num(v)
end
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])
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)
end
end
if #parts == 0 then
return nil
end
return table.concat(parts, ", ")
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».
local function healText(spec)
if type(spec) ~= "table" then
return nil
end
local neg = { types = {}, groups = {} }
local any = false
for t, v in pairs(spec.types or {}) do
if v < 0 then
neg.types[t] = -v
any = true
end
end
for g, v in pairs(spec.groups or {}) do
if v < 0 then
neg.groups[g] = -v
any = true
end
end
return any and damageText(neg, "positive") or nil
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
-- Список «Основная информация».
local function basicLines(id, mob)
local lines = {}
-- Пороги состояний
local thr = comp("MobThresholds", mob)
local th = thr and thr("thresholds")
if not isEmpty(th) then
local parts = {}
local hasCrit = false
for _, k in ipairs(sortedNumKeys(th)) do
local state = th[k]
if state == "PreCritical" then
parts[#parts + 1] = "подкрит — с " .. num(k) .. " урона"
elseif state == "Critical" then
parts[#parts + 1] = "критическое состояние — с " .. num(k) .. " урона"
hasCrit = true
elseif state == "Dead" then
parts[#parts + 1] = "смерть — с " .. num(k) .. " урона"
break
end
end
local line = "* '''Здоровье''': " .. table.concat(parts, ", ") .. "."
if not hasCrit then
line = line .. " Критического состояния нет."
end
lines[#lines + 1] = line
end
-- Скорость
local ms = comp("MovementSpeedModifier", mob)
if ms then
lines[#lines + 1] = "* '''Скорость передвижения''': шаг " .. num(ms("baseWalkSpeed") or 0)
.. ", бег " .. num(ms("baseSprintSpeed") or 0) .. "."
local baseMs = comp("MovementSpeedModifier", speciesMob(BASELINE_SPECIES))
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] = "** Ускорение в невесомости: ×" .. num(acc / baseAcc) .. " от человеческого."
end
end
-- Замедление от урона
local slow = comp("SlowOnDamage", mob)
local st = slow and slow("speedModifierThresholds")
if not isEmpty(st) then
local parts = {}
for _, k in ipairs(sortedNumKeys(st)) do
parts[#parts + 1] = "с " .. num(k) .. " урона — ×" .. num(st[k])
end
lines[#lines + 1] = "* '''Замедление от урона''': " .. table.concat(parts, ", ") .. "."
end
-- Кровь
local bs = comp("Bloodstream", mob)
if not bs then
lines[#lines + 1] = "* '''Крови нет'''."
else
local reagent, volume
local ref = bs("bloodReferenceSolution")
if type(ref) == "table" and not isEmpty(ref.reagents) then
local r = ref.reagents[1]
reagent, volume = r.ReagentId, r.Quantity
else
reagent, volume = bs("bloodReagent"), bs("bloodMaxVolume")
end
local line = "* '''Кровь''': " .. (reagent and reagentName(reagent) or "?")
if volume == 0 then
line = "* '''Крови нет''' (объём крови 0)"
elseif volume then
line = line .. ", " .. num(volume) .. " ед."
end
lines[#lines + 1] = line
local loss = damageText(bs("bloodlossDamage"), "negative")
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] = "** Не истекает кровью."
elseif bs("bleedReductionAmount") == 0 then
lines[#lines + 1] = "** Кровотечение само не останавливается."
end
if bs("bloodRefreshAmount") == 0 and volume ~= 0 then
lines[#lines + 1] = "** Кровь сама не восстанавливается."
end
end
-- Голод и жажда
local baseMob = speciesMob(BASELINE_SPECIES)
for _, kind in ipairs({ "Hunger", "Thirst" }) do
local rate = satiationRate(mob, kind)
local label = kind == "Hunger" and "Голод" or "Жажда"
if rate == nil then
if baseMob ~= mob and satiationRate(baseMob, kind) then
lines[#lines + 1] = "* '''" .. label .. "''': не испытывает."
end
else
local cmp = rateCompare(rate, satiationRate(baseMob, kind))
if cmp then
lines[#lines + 1] = "* '''" .. label .. "''' нарастает " .. cmp .. "."
end
end
end
-- Естественная регенерация
local pd = comp("PassiveDamage", mob)
local heal = pd and healText(pd("damage"))
if heal then
local line = "* '''Естественная регенерация''': " .. heal .. " в секунду"
local cap = pd("damageCap")
if cap and cap > 0 then
line = line .. ", пока общий урон не больше " .. num(cap)
end
lines[#lines + 1] = line .. "."
end
return lines
end
-- Список «Окружающая среда».
local function environmentLines(mob, orgs)
local lines = {}
local baseResp = comp("Respirator", speciesMob(BASELINE_SPECIES))
-- Дыхание
local resp = comp("Respirator", mob)
if not resp then
lines[#lines + 1] = "* '''Не дышит''' и не задыхается."
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(speciesMob(BASELINE_SPECIES)))
if baseTypes then
local _, baseHarm = gases(baseTypes)
local extra = reagentList(harm, baseHarm)
if #extra > 0 then
lines[#lines + 1] = "** В отличие от человека, вредны: " .. table.concat(extra, ", ") .. "."
end
local safe = reagentList(baseHarm, harm, breath)
if #safe > 0 then
lines[#lines + 1] = "** В отличие от человека, не вредны: " .. table.concat(safe, ", ") .. "."
end
end
end
local dmg = damageText(resp("damage"), "negative")
local rec = healText(resp("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] = "** Запас воздуха: ×" .. num(sat / baseSat) .. " от человеческого."
end
end
-- Температура (новая схема: TemperatureDamage, Goob: Temperature)
local t = comp("TemperatureDamage", mob) or comp("Temperature", mob)
if t and t("heatDamageThreshold") then
local cold, heat = t("coldDamageThreshold"), t("heatDamageThreshold")
local coldDmg, heatDmg = damageText(t("coldDamage"), "negative"), damageText(t("heatDamage"), "negative")
if cold and cold > 0 and coldDmg then
lines[#lines + 1] = "* '''Безопасная температура''': от " .. kelvinToC(cold) .. " до " .. kelvinToC(heat) .. "."
lines[#lines + 1] = "** Ниже — " .. coldDmg .. " в секунду."
else
lines[#lines + 1] = "* '''Безопасная температура''': до " .. kelvinToC(heat) .. ", холода не боится."
end
if heatDmg then
lines[#lines + 1] = "** Выше — " .. heatDmg .. " в секунду."
end
end
-- Давление
local baro = comp("Barotrauma", mob)
if not baro or damageSum(baro("damage")) == 0 then
lines[#lines + 1] = "* '''Давление''': урона от низкого и высокого давления не получает."
else
lines[#lines + 1] = "* '''Давление''': без защиты в вакууме и при высоком давлении получает в секунду до: "
.. damageText(baro("damage"), "negative") .. " (зависит от давления)."
end
return lines
end
-- Список «Боевые характеристики».
local function combatLines(mob)
local lines = {}
local mw_ = comp("MeleeWeapon", mob)
local dmg = mw_ and damageText(mw_("damage"))
if dmg then
local line = "* '''Безоружная атака''': " .. dmg .. "."
local rate = mw_("attackRate")
if rate and math.abs(rate - 1) > 0.001 then
line = line .. " Скорость атаки: " .. num(rate) .. " удара в секунду."
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] = "* '''Лечат''': " .. capped(h, 20) .. "."
end
if #x > 0 then
lines[#lines + 1] = "* '''Вредят''': " .. capped(x, 20) .. "."
end
if #b > 0 then
lines[#lines + 1] = "* '''Не лечат''' (человека лечат): " .. capped(b, 20) .. "."
end
if #n > 0 then
lines[#lines + 1] = "* '''Не вредят''' (человеку вредят): " .. 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 = damageText(c("fallDamage"), "negative")
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 = damageText(c("flashDamage"), "negative")
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 = damageText(c("damage"), "negative")
return d and ("Сами получают урон при каждом ударе (" .. d .. ").") or nil
end },
{ "DamageOnLand", function(c)
local d = damageText(c("damage"), "negative")
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 "слабее") .. ", чем человек: " .. damageText(f("damage"), "negative") .. " в секунду при горении."
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
function p.stats(frame)
local id, args = argId(frame)
local mob = speciesMob(id)
if not mob then
return ""
end
local level = args.level or "==="
local orgs = organs(mob)
local out = {}
local function section(title, lines)
if #lines > 0 then
out[#out + 1] = level .. " " .. title .. " " .. 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)
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
local level = args.level or "==="
text = level .. " " .. args.header .. " " .. level .. "\n" .. text
end
return text
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