Модуль:Component

Материал из Space Station 14 Вики
Версия от 02:46, 26 октября 2026; Halch (обсуждение | вклад) (Пилот «Особенности рас»: новая страница (шаблоны Component))
(разн.) ← Предыдущая версия | Текущая версия (разн.) | Следующая версия → (разн.)

Для документации этого модуля может быть создана страница Модуль:Component/doc

-- Модуль:Component
-- Расчёты для шаблонов компонентов Шаблон:Component/<Компонент>.
--
-- Как устроено:
--   {{Сущность}} для каждого компонента сущности вызывает Шаблон:Component/<Компонент>.
--   Шаблон, как и остальные шаблоны Component/…, берёт простые поля напрямую ({{{поле}}},
--   {{serialization/damage}}, Модуль:GetField). Этот модуль нужен только там, где данных
--   одного компонента мало: органы, газы, особые вещества, кровь, сравнение с человеком.
--   Список функций — в разделе «Функции для шаблонов» в конце модуля.

-- Используемые данные бота:
--   component/*.json                  — поля компонентов
--   prototype/Species.json            — моб человека для сравнения
--   prototype/DamageType.json, prototype/DamageGroup.json — названия типов урона
--   prototype/Reagent.json            — кровь, газы, особые вещества
--   prototype/Satiation.json          — голод и жажда (новая схема)
--   prototype/Body.json               — органы (старая схема тела, Goob)
--   prototype/MetabolismStage.json, prototype/MetabolismGroup.json, prototype/MetabolizerType.json — органы
--   loc.json                          — русская локализация
--
-- КАК ПРАВИТЬ
--   * Поменять формулировку — правьте шаблон Шаблон:Component/<Компонент>, а не модуль.
--   * Расцветка: зелёный (class="positive") — хорошее (лечение, преимущества),
--     красный (class="negative") — плохое (урон, недостатки), жёлтый — прочие числа.
--     Жёлтый задан в NUMBER_STYLE.
--   * С кем сравнивать (ratio, satiation, gases, reagents) — BASELINE_SPECIES, по умолчанию человек.
--   * Порядок групп урона и органов — GROUP_ORDER и ORGAN_ORDER.
--   * После правки проверьте страницу во всех проектах через переключатель проекта:
--     у Goob другая схема тела.
--   * Вспомогательные функции скопированы из Модуль:SpeciesStats. Когда все расы
--     переедут на шаблоны, SpeciesStats оставит себе только карточку расы и списки рас,
--     а расчёты будут только здесь.

local p = {}

local getArgs = require('Module:Arguments').getArgs
local JsonPaths = require('Module:JsonPaths')
-- Модуль:GetField — общий модуль вики для чтения выгрузки бота. Через него
-- грузятся все файлы данных, чтобы путь к данным проекта выбирался одинаково
-- на всех страницах (Corvax / Goob / Wega / WL).
local GetField = require('Module:GetField')

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
		-- GetField.loadCachedData сам выбирает проект (через JsonPaths)
		-- и возвращает nil, если файла нет.
		cache[rel] = GetField.loadCachedData(rel) 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 sortedNumKeys(t)
	local keys = {}
	for k in pairs(t or {}) do
		if tonumber(k) then
			keys[#keys + 1] = k
		end
	end
	table.sort(keys, function(a, b) return tonumber(a) < tonumber(b) end)
	return keys
end

-- Типы урона в порядке групп; возвращает список { id, value }.
local function orderedTypes(map)
	local out, seen = {}, {}
	for _, g in ipairs(GROUP_ORDER) do
		local dg = getEntry("prototype/DamageGroup.json", g)
		for _, t in ipairs(list(dg and dg("damageTypes"))) do
			if map[t] ~= nil and not seen[t] then
				out[#out + 1] = { t, map[t] }
				seen[t] = true
			end
		end
	end
	-- типы, которых нет ни в одной известной группе
	local rest = {}
	for t, v in pairs(map) do
		if not seen[t] then
			rest[#rest + 1] = { t, v }
		end
	end
	table.sort(rest, function(a, b) return a[1] < b[1] end)
	for _, e in ipairs(rest) do
		out[#out + 1] = e
	end
	return out
end

-- Расцветка страницы (сравнения цветом с человеком нет):
--   зелёный (class="positive") — положительное: лечение, преимущества, баффы;
--   красный (class="negative") — отрицательное: получаемый урон, недостатки, дебаффы;
--   жёлтый (NUMBER_STYLE) — остальные числа (здоровье, скорость, температура и т. п.).
-- Чтобы поменять жёлтый, правьте NUMBER_STYLE.
local NUMBER_STYLE = "color:#e6c07b;font-weight:bold"

-- Обычное число (не урон и не лечение) — жёлтым. v — само число: если это не число,
-- текст выводится без цвета.
local function mark(text, v)
	if type(v) == "number" then
		return '<span style="' .. NUMBER_STYLE .. '">' .. text .. '</span>'
	end
	return text
end

-- Температура: в кельвинах, как в игре, а °C — справочно в скобках: «285 K (11,85 °C)».
-- Число в кельвинах выделяется жёлтым (функция mark выше).
local function temperature(k)
	return mark(num(k) .. " K", k) .. " (" .. num(k - 273.15) .. " °C)"
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».
-- good: true — числа зелёные (лечение), false — красные (урон), nil — без цвета.
local function damageText(spec, good)
	if type(spec) ~= "table" then
		return nil
	end
	local function value(v)
		if good == nil then
			return num(v)
		end
		return tone(num(v), good)
	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

-- Урон, который получает раса: числа красные.
local function takenText(spec)
	return damageText(spec, 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», числа зелёные.
local function healText(spec)
	local neg = { types = {}, groups = {} }
	local any = false
	if type(spec) == "table" then
		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
	end
	if not any then
		return nil
	end
	return damageText(neg, 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
-- Пороги состояний моба: словарь «урон → состояние» ({ ["100"] = "Critical", ... }).
local function thresholdsOf(mob)
	local thr = mob and comp("MobThresholds", mob)
	return thr and thr("thresholds")
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

-- Название органа. Если у органа нет своего названия (например, у органов дворфа
-- в 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


---------------------------------------------------------------------------
-- Функции для шаблонов Шаблон:Component/<Компонент>
---------------------------------------------------------------------------
-- Здесь только то, что нельзя достать из полей компонента напрямую через {{{поле}}}:
-- данные из нескольких выгрузок сразу и сравнение с человеком.
-- Вызов как у Модуль:GetField — первым параметром ID сущности:
--   {{#invoke:Component|organs|{{{id}}}}}
-- Если данных нет, функция возвращает пустую строку: в шаблоне это проверяется
-- через {{#vardefine:…}} и {{#if:{{#var:…}}|…}}.
--
--   blood|{{{id}}}                    — название крови («Кровь»)
--   bloodVolume|{{{id}}}              — объём крови («600»)
--   satiation|{{{id}}}|Hunger         — голод (Thirst — жажда) в сравнении с человеком:
--                                       «в 2 раза быстрее, чем у человека», «не испытывает»
--   ratio|{{{id}}}|Компонент|поле     — во сколько раз поле больше, чем у человека («1.5»);
--                                       пусто, если так же
--   gases|{{{id}}}|breath             — чем дышит; harm — вредные газы, которые не вредны
--                                       человеку; safe — вредные человеку, но не ей
--   reagents|{{{id}}}|heal            — вещества, которые действуют не так, как на человека:
--                                       heal (лечат), harm (вредят), blocked (не лечат),
--                                       safe (не вредят); больше 20 — «и ещё N»
--   organs|{{{id}}}                   — список органов, по пункту «* » на орган
--   hasDamage|{{{id}}}|Компонент|поле — «1», если суммарный урон в поле больше 0 (иначе пусто)
--   flammable|{{{id}}}                — горение по сравнению с человеком: «сильнее», «слабее»,
--                                       «нет» (огонь не наносит урона) или пусто (так же / не моб)

local function idArg(frame)
	local args = getArgs(frame)
	return mw.text.trim(args[1] or ""), args
end

-- Моб человека, с которым идёт сравнение.
local function baseMob()
	return speciesMob(BASELINE_SPECIES)
end

-- Число с точкой, до сотых: как {{{поле}}} в остальных шаблонах вики.
local function plain(x)
	return tostring(math.floor(x * 100 + 0.5) / 100)
end

-- Новая схема крови (bloodReferenceSolution) и старая (bloodReagent, bloodMaxVolume).
function p.blood(frame)
	local id = idArg(frame)
	local reagent = bloodOf(id ~= "" and comp("Bloodstream", id))
	return reagent and reagentName(reagent) or ""
end

function p.bloodVolume(frame)
	local id = idArg(frame)
	local _, volume = bloodOf(id ~= "" and comp("Bloodstream", id))
	volume = tonumber(volume)
	return volume and plain(volume) or ""
end

function p.satiation(frame)
	local id, args = idArg(frame)
	if id == "" then
		return ""
	end
	local kind = args[2] == "Thirst" and "Thirst" or "Hunger"
	local base = baseMob()
	local rate, baseRate = satiationRate(id, kind), satiationRate(base, kind)
	if rate == nil then
		return (baseRate and id ~= base) and "не испытывает" or ""
	end
	return rateCompare(rate, baseRate) or ""
end

function p.ratio(frame)
	local id, args = idArg(frame)
	local c = id ~= "" and comp(args[2] or "", id)
	local b = comp(args[2] or "", baseMob())
	local v = c and tonumber(c(args[3] or ""))
	local base = b and tonumber(b(args[3] or ""))
	if not v or not base or base == 0 or math.abs(v / base - 1) <= 0.01 then
		return ""
	end
	return plain(v / base)
end

-- Газы определяются по типу метаболизма лёгких сущности.
function p.gases(frame)
	local id, args = idArg(frame)
	local types = id ~= "" and lungTypes(organs(id))
	if not types then
		return ""
	end
	local breath, harm = gases(types)
	local mode = args[2] or "breath"
	local out
	if mode == "breath" then
		out = reagentList(breath)
	else
		local baseTypes = lungTypes(organs(baseMob()))
		if not baseTypes then
			return ""
		end
		local _, baseHarm = gases(baseTypes)
		if mode == "harm" then
			out = reagentList(harm, baseHarm)
		else
			out = reagentList(baseHarm, harm, breath)
		end
	end
	return table.concat(out, ", ")
end

function p.reagents(frame)
	local id, args = idArg(frame)
	local types = id ~= "" and bodyTypes(organs(id)) or {}
	if #types == 0 then
		return ""
	end
	local base = baseMob()
	local heal, harm, blocked, safe = specialReagents(types, base and bodyTypes(organs(base)) or {})
	local set = ({ heal = heal, harm = harm, blocked = blocked, safe = safe })[args[2] or ""]
	if not set then
		return ""
	end
	local items = reagentList(set)
	return #items > 0 and capped(items, 20) or ""
end

-- Новая схема тела (InitialBody) и старая (Body, Goob).
function p.organs(frame)
	local id = idArg(frame)
	if id == "" then
		return ""
	end
	local lines = {}
	for _, o in ipairs(organs(id)) 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] = "одновременно обрабатывает веществ: '''" .. plain(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] = "срабатывает раз в '''" .. plain(o.interval) .. "''' сек."
		end
		if #details > 0 then
			line = line .. " — " .. table.concat(details, "; ")
		end
		lines[#lines + 1] = line .. "."
	end
	return table.concat(lines, "\n")
end

-- Только для мобов (есть MobState): у предметов сравнение с человеком не имеет смысла.
function p.flammable(frame)
	local id = idArg(frame)
	local f = id ~= "" and comp("MobState", id) and comp("Flammable", id)
	local base = comp("Flammable", baseMob())
	if not f or not base or id == baseMob() then
		return ""
	end
	local a, b = damageSum(f("damage")), damageSum(base("damage"))
	if b == 0 or math.abs(a / b - 1) < 0.01 then
		return ""
	end
	if a == 0 then
		return "нет"
	end
	return a > b and "сильнее" or "слабее"
end

-- Урон вида {types = {...}} бывает «пустым» (все значения 0): тогда строку выводить не нужно.
function p.hasDamage(frame)
	local id, args = idArg(frame)
	local c = id ~= "" and comp(args[2] or "", id)
	return (c and damageSum(c(args[3] or "")) > 0) and "1" or ""
end

return p