Модуль:SpeciesStats

Материал из Space Station 14 Вики
Версия от 23:49, 13 октября 2026; Halch (обсуждение | вклад)
(разн.) ← Предыдущая версия | Текущая версия (разн.) | Следующая версия → (разн.)

Для документации этого модуля может быть создана страница Модуль: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= у нужной расы.
--   * Расцветка: всё сравнивается с человеком (BASELINE_SPECIES). Зелёный — лучше,
--     красный — хуже, жёлтый — как у человека (цвет задаёт SAME_STYLE). Для чисел используйте mark(),
--     для урона — takenText(), для лечения — healText(), для фраз — tone().
--   * Порядок групп урона и органов задают 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

-- Цвет значений, которые совпадают с человеческими (нейтральный жёлтый).
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 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 then
			return mark(num(v), v, b or 0, 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

function p.stats(frame)
	local id, args = argId(frame)
	isBaseline = (id == BASELINE_SPECIES)
	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)
	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
		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