Модуль:MedicalStats

Версия от 12:53, 13 октября 2026; Halch (обсуждение | вклад) (Новая страница: «-- Модуль:MedicalStats -- Автоматическая памятка медика: сколько лечит/наносит урона лекарство за 1u, -- с какой дозы начинается передозировка, что делают бинты, мази, койки и дефибриллятор. -- -- Все цифры берутся из выгрузки IanComradeBot: -- prototype/Reagent.json — метаболиз...»)
(разн.) ← Предыдущая версия | Текущая версия (разн.) | Следующая версия → (разн.)

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

-- Модуль:MedicalStats
-- Автоматическая памятка медика: сколько лечит/наносит урона лекарство за 1u,
-- с какой дозы начинается передозировка, что делают бинты, мази, койки и дефибриллятор.
--
-- Все цифры берутся из выгрузки IanComradeBot:
--   prototype/Reagent.json        — метаболизм реагентов (эффекты, условия, metabolismRate)
--   component/Healing.json        — бинты, мази, наборы (компонент Healing)
--   component/HealOnBuckle.json   — кровати и койки (компонент HealOnBuckle)
--   component/Defibrillator.json  — дефибрилляторы
--   prototype/DamageType.json, prototype/DamageGroup.json — типы и группы урона
--   loc.json                      — русская локализация (ru-RU)
--   entity prototypes.json        — названия сущностей
--
-- Как считается «за 1u»: раз в секунду (тик метаболизма) из крови убирается
-- metabolismRate единиц реагента (по умолчанию 0.5u) и эффекты применяются один раз.
-- Значит, эффект на 1u = значение эффекта / metabolismRate.

local p = {}

local getArgs = require('Module:Arguments').getArgs
local JsonPaths = require('Module:JsonPaths')

local lang = mw.getContentLanguage()

-- В игре metabolismRate по умолчанию 0.5 (ReagentEffectsEntry.MetabolismRate).
-- Бот не выгружает значения по умолчанию, поэтому оно продублировано здесь.
local DEFAULT_METABOLISM_RATE = 0.5

-- Значения DataField по умолчанию для эффектов, которые бот не выгружает.
local EFFECT_DEFAULTS = {
	ModifyBleed = { amount = -1 },
	ModifyBloodLevel = { amount = 1 },
	CleanBloodstream = { cleanseRate = 3 },
	EyeDamage = { amount = -1 },
}

-- Порядок групп урона при выводе (только порядок, сами группы и их типы — из данных бота).
local GROUP_ORDER = { "Brute", "Burn", "Airloss", "Toxin", "Genetic", "Metaphysical" }

---------------------------------------------------------------------------
-- Загрузка данных (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)
	for _ in pairs(t) do
		return false
	end
	return true
end

-- Запись прототипа/компонента из файла формата { default = {...}, id = { Id = {...} } }.
-- Возвращает функцию-геттер поля с подстановкой default.
local function getEntry(rel, id)
	local data = load(rel)
	local ids = data.id or {}
	local entry = ids[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 or key
end

local function ucfirst(s)
	return s and lang:ucfirst(s) or s
end

---------------------------------------------------------------------------
-- Форматирование
---------------------------------------------------------------------------

-- Число с округлением до сотых, десятичная запятая по правилам ru.
local function num(x)
	x = math.floor(math.abs(x) * 100 + 0.5) / 100
	return lang:formatNum(x)
end

local function good(x)
	return '<span style="color:green">' .. num(x) .. '</span>'
end

local function bad(x)
	return '<span style="color:red">' .. num(x) .. '</span>'
end

local function typeName(typeId)
	local dt = getEntry("prototype/DamageType.json", typeId)
	return ucfirst(loc(dt and dt("name"))) or typeId
end

local function groupName(groupId)
	local dg = getEntry("prototype/DamageGroup.json", groupId)
	return ucfirst(loc(dg and dg("name"))) or groupId
end

local function groupTypes(groupId)
	local dg = getEntry("prototype/DamageGroup.json", groupId)
	local list = {}
	for _, t in ipairs(dg and dg("damageTypes") or {}) do
		list[#list + 1] = t
	end
	return list
end

local function reagentName(id)
	local r = getEntry("prototype/Reagent.json", id)
	return ucfirst(loc(r and r("name"))) or id
end

local function reagentLink(id)
	return "[[Химия#chem_" .. id .. "|" .. reagentName(id) .. "]]"
end

local function entityName(id)
	local overrides = load("entity name overrides.json")
	if overrides[id] then
		return overrides[id]
	end
	local ent = load("entity prototypes.json")[id]
	return ent and ent.name or id
end

---------------------------------------------------------------------------
-- Урон: накопление и вывод
---------------------------------------------------------------------------

-- Порядок ключей урона: группы по GROUP_ORDER, внутри — типы в порядке группы.
local function damageOrder()
	if cache._order then
		return cache._order
	end
	local order, n = {}, 0
	for _, g in ipairs(GROUP_ORDER) do
		n = n + 1
		order["g:" .. g] = n
		for _, t in ipairs(groupTypes(g)) do
			n = n + 1
			order["t:" .. t] = n
		end
	end
	cache._order = order
	return order
end

-- acc = { list = { key, ... }, val = { key = число } }
local function newAcc()
	return { list = {}, val = {} }
end

local function accAdd(acc, key, amount)
	if amount == 0 then
		return
	end
	if acc.val[key] == nil then
		acc.list[#acc.list + 1] = key
		acc.val[key] = 0
	end
	acc.val[key] = acc.val[key] + amount
end

-- DamageSpecifier: { types = {...}, groups = {...} }. Группа делится поровну между её типами.
local function addSpecifier(acc, spec, k)
	if type(spec) ~= "table" then
		return
	end
	for t, v in pairs(spec.types or {}) do
		accAdd(acc, "t:" .. t, v * k)
	end
	for g, v in pairs(spec.groups or {}) do
		local types = groupTypes(g)
		for _, t in ipairs(types) do
			accAdd(acc, "t:" .. t, v * k / #types)
		end
	end
end

-- Название ключа урона с числом.
-- Тип: «Ушибы 9».
-- Группа (EvenHealthChange): «Механические 3 (по 1 на Ушибы, Порезы, Уколы)» —
-- в игре лечение группы делится поровну между её типами (DamageableSystem.HealEvenly),
-- а если часть типов не повреждена, остаток уходит в повреждённые.
local function keyText(key, v, colorize)
	local kind, id = key:match("^(%a):(.+)$")
	if kind ~= "g" then
		return typeName(id) .. " " .. colorize(v)
	end
	local types = groupTypes(id)
	local text = groupName(id) .. " " .. colorize(v)
	if #types > 1 then
		local names = {}
		for _, t in ipairs(types) do
			names[#names + 1] = typeName(t)
		end
		text = text .. " (по " .. num(v / #types) .. " на " .. table.concat(names, ", ") .. ")"
	end
	return text
end

-- Возвращает строки «Лечит: …» и «Наносит: …».
local function renderDamage(acc)
	local order = damageOrder()
	local keys = {}
	for _, key in ipairs(acc.list) do
		if math.abs(acc.val[key]) >= 0.005 then
			keys[#keys + 1] = key
		end
	end
	table.sort(keys, function(a, b)
		local oa, ob = order[a] or 1000, order[b] or 1000
		if oa ~= ob then
			return oa < ob
		end
		return a < b
	end)

	local heals, deals = {}, {}
	for _, key in ipairs(keys) do
		local v = acc.val[key]
		if v < 0 then
			heals[#heals + 1] = keyText(key, v, good)
		else
			deals[#deals + 1] = keyText(key, v, bad)
		end
	end

	local out = {}
	if #heals > 0 then
		out[#out + 1] = "лечит: " .. table.concat(heals, ", ")
	end
	if #deals > 0 then
		out[#out + 1] = "наносит: " .. table.concat(deals, ", ")
	end
	return out
end

---------------------------------------------------------------------------
-- Реагенты
---------------------------------------------------------------------------

-- Разбор записи эффекта бота: { ["!type:HealthChange"] = { ... } } -> "HealthChange", { ... }
local function unwrap(obj)
	for key, val in pairs(obj) do
		local t = type(key) == "string" and key:match("^!type:(.+)$")
		if t then
			return t, val
		end
	end
	return nil, nil
end

-- Старые имена эффектов (до переезда на EntityEffects; так их пока выгружает бот для Goob)
local EFFECT_ALIASES = {
	ChemVomit = "Vomit",
	ChemHealEyeDamage = "EyeDamage",
	ChemCleanBloodstream = "CleanBloodstream",
	ModifyBleedAmount = "ModifyBleed",
}

local function field(effType, eff, name)
	local v = eff[name]
	if v == nil and EFFECT_DEFAULTS[effType] then
		v = EFFECT_DEFAULTS[effType][name]
	end
	return v
end

-- Условия эффекта: { self = {min=, max=}, others = { "текст", ... } }
-- Старые/форковые имена условий -> имена, которые понимает модуль.
-- ReagentThreshold, Temperature, TotalDamage, OrganType — старая схема (так их пока выгружает бот для Goob).
local CONDITION_ALIASES = {
	ReagentThreshold = "ReagentCondition",
	Temperature = "TemperatureCondition",
	TotalDamage = "TotalDamageCondition",
	OrganType = "MetabolizerTypeCondition",
}

local function parseConditions(eff, reagentId)
	local res = { others = {} }
	for _, c in ipairs(eff.conditions or {}) do
		local ct, cond = unwrap(c)
		ct = CONDITION_ALIASES[ct] or ct
		cond = cond or {}
		local inv = cond.inverted
		if ct == "ReagentCondition" and cond.reagent == nil then
			-- в ReagentThreshold реагент по умолчанию — сам метаболизируемый реагент
			cond = { reagent = reagentId, min = cond.min, max = cond.max, inverted = cond.inverted }
		end
		if ct == "ReagentCondition" and cond.reagent == reagentId and not inv then
			if cond.min and cond.min > 0 then
				res.selfMin = cond.min
			end
			if cond.max then
				res.selfMax = cond.max
			end
		elseif ct == "ReagentCondition" then
			local what = reagentLink(cond.reagent)
			local text
			if cond.min and cond.min > 1 then
				text = "если в крови от " .. num(cond.min) .. "u вещества " .. what
			else
				text = "если в крови есть " .. what
			end
			if cond.max then
				text = text .. " (не больше " .. num(cond.max) .. "u)"
			end
			res.others[#res.others + 1] = inv and ("кроме случая: " .. text) or text
		elseif ct == "MobStateCondition" then
			local state = cond.mobstate or "Alive"
			local names = {
				Alive = "не в критическом состоянии",
				Critical = "в критическом состоянии",
				Dead = "у мёртвых",
			}
			local s = names[state] or state
			res.others[#res.others + 1] = (inv and "только не " or "только ") .. s
		elseif ct == "TemperatureCondition" then
			local parts = {}
			if cond.min then
				parts[#parts + 1] = "выше " .. num(cond.min) .. " K (" .. (cond.min < 273.15 and "−" or "") .. num(cond.min - 273.15) .. " °C)"
			end
			if cond.max then
				parts[#parts + 1] = "ниже " .. num(cond.max) .. " K (" .. (cond.max < 273.15 and "−" or "") .. num(cond.max - 273.15) .. " °C)"
			end
			res.others[#res.others + 1] = "при температуре тела " .. table.concat(parts, " и ")
		elseif ct == "TotalDamageCondition" then
			local parts = {}
			if cond.min then
				parts[#parts + 1] = "от " .. num(cond.min)
			end
			if cond.max then
				parts[#parts + 1] = "до " .. num(cond.max)
			end
			res.others[#res.others + 1] = (inv and "кроме случая: " or "") .. "при общем уроне " .. table.concat(parts, " ")
		elseif ct == "MetabolizerTypeCondition" then
			-- Исключения для отдельных рас (например, «кроме Yowie» в Goob) в памятке не показываем,
			-- а «только для …» показываем.
			local excluded = inv or cond.shouldHave == false
			if not excluded then
				local types = cond.type
				if type(types) ~= "table" then
					types = { types }
				end
				local names = {}
				for _, t in ipairs(types) do
					local mt = getEntry("prototype/MetabolizerType.json", t)
					names[#names + 1] = ucfirst(loc(mt and mt("name"))) or t
				end
				res.others[#res.others + 1] = "только для метаболизма: " .. table.concat(names, ", ")
			end
		elseif ct then
			res.others[#res.others + 1] = "при особом условии (" .. ct .. ")"
		end
	end
	return res
end

-- Текст «прочих» (не уронных) эффектов. k — множитель «за 1u».
local function otherEffectText(effType, eff, k, reagentId)
	if effType == "ModifyBleed" then
		local a = field(effType, eff, "amount") * k
		if a < 0 then
			return "уменьшает кровотечение на " .. good(a)
		end
		return "усиливает кровотечение на " .. bad(a)
	elseif effType == "ModifyBloodLevel" then
		local a = field(effType, eff, "amount") * k
		if a > 0 then
			return "восполняет кровь на " .. good(a) .. "u"
		end
		return "уменьшает объём крови на " .. bad(a) .. "u"
	elseif effType == "CleanBloodstream" then
		local a = field(effType, eff, "cleanseRate") * k
		return "выводит из крови по " .. good(a) .. "u остальных веществ"
	elseif effType == "AdjustReagent" then
		local a = (eff.amount or 0) * k
		if eff.reagent == reagentId then
			return nil -- сам себя выводит/добавляет — для памятки не важно
		end
		if a < 0 then
			return "выводит " .. good(a) .. "u вещества " .. reagentLink(eff.reagent)
		end
		return "добавляет " .. bad(a) .. "u вещества " .. reagentLink(eff.reagent)
	elseif effType == "EyeDamage" then
		local a = field(effType, eff, "amount") * k
		if a < 0 then
			return "лечит повреждение глаз на " .. good(a)
		end
		return "повреждает глаза на " .. bad(a)
	elseif effType == "CureZombieInfection" then
		if eff.innoculate then
			return "излечивает [[зомби]]-вирус и даёт иммунитет к нему"
		end
		return "излечивает [[зомби]]-вирус"
	elseif effType == "AdjustTemperature" then
		return (eff.amount or 0) < 0 and "охлаждает тело" or "нагревает тело"
	elseif effType == "ModifyKnockdown" or effType == "ModifyStatusEffect" or effType == "ModifyParalysis" then
		if eff.type == "Remove" then
			return "сокращает оглушение и сбивание с ног"
		end
		return nil
	elseif effType == "SuppressPain" then
		return "подавляет боль"
	elseif effType == "Vomit" then
		return "рвота"
	elseif effType == "Jitter" then
		return "дрожь"
	elseif effType == "Drunk" then
		return "опьянение"
	end
	-- PopupMessage, Emote, SatiateHunger и т.п. в памятке не показываем
	return nil
end

local function isDamageEffect(effType)
	return effType == "HealthChange" or effType == "EvenHealthChange"
end

-- Есть ли у уронного эффекта положительный (вредный) урон
local function hasHarm(effType, eff)
	local d = eff.damage or {}
	if effType == "EvenHealthChange" then
		for _, v in pairs(d) do
			if v > 0 then return true end
		end
		return false
	end
	for _, part in pairs({ d.types or {}, d.groups or {} }) do
		for _, v in pairs(part) do
			if v > 0 then return true end
		end
	end
	return false
end

-- Эффекты, которые при пороговой дозе являются полезными (а не передозировкой)
local BENEFICIAL = { CureZombieInfection = true, SuppressPain = true, EyeDamage = true, ModifyBleed = true, ModifyBloodLevel = true }

-- Группа эффектов с одинаковыми условиями: { label = "…", acc = урон, extra = { "…" } }
local function bucketFor(buckets, label)
	for _, b in ipairs(buckets) do
		if b.label == label then
			return b
		end
	end
	local b = { label = label, acc = newAcc(), extra = {} }
	buckets[#buckets + 1] = b
	return b
end

local function addExtra(b, text)
	if not text then return end
	for _, e in ipairs(b.extra) do
		if e == text then return end
	end
	b.extra[#b.extra + 1] = text
end

local function renderBucket(b)
	local parts = renderDamage(b.acc)
	for _, e in ipairs(b.extra) do
		parts[#parts + 1] = e
	end
	if #parts == 0 then
		return nil
	end
	local text = table.concat(parts, "; ")
	if b.label ~= "" then
		return ucfirst(b.label) .. ": " .. text .. "."
	end
	return ucfirst(text) .. "."
end

-- Метаболизм реагента: Bloodstream, иначе первый найденный (например, Digestion у напитков)
local function pickMetabolism(metabolisms)
	if type(metabolisms) ~= "table" then
		return nil
	end
	-- Bloodstream — Corvax/апстрим, Medicine — группа метаболизма лекарств в Goob
	for _, key in ipairs({ "Bloodstream", "Medicine" }) do
		if metabolisms[key] then
			return metabolisms[key]
		end
	end
	local firstKey
	for key in pairs(metabolisms) do
		if not firstKey or key < firstKey then
			firstKey = key
		end
	end
	return firstKey and metabolisms[firstKey]
end

-- Полный разбор реагента
local function analyzeReagent(id)
	local r = getEntry("prototype/Reagent.json", id)
	if not r then
		return nil
	end
	local m = pickMetabolism(r("metabolisms"))
	local res = { rate = DEFAULT_METABOLISM_RATE, normal = {}, overdose = {}, threshold = nil }
	if not m then
		return res
	end
	res.rate = m.metabolismRate or DEFAULT_METABOLISM_RATE
	local k = 1 / res.rate

	bucketFor(res.normal, "") -- безусловные эффекты всегда первыми

	local harmThreshold, anyThreshold
	for _, wrapped in ipairs(m.effects or {}) do
		local effType, eff = unwrap(wrapped)
		effType = EFFECT_ALIASES[effType] or effType
		if effType and eff then
			local cond = parseConditions(eff, id)
			local label = table.concat(cond.others, ", ")
			if cond.selfMax then
				label = (label ~= "" and (label .. ", ") or "") .. "пока в крови не больше " .. num(cond.selfMax) .. "u"
			end
			if eff.probability and eff.probability < 1 then
				label = (label ~= "" and (label .. ", ") or "") .. "с шансом " .. num(eff.probability * 100) .. "% в секунду"
			end

			local isOverdose = false
			if cond.selfMin then
				if isDamageEffect(effType) then
					isOverdose = hasHarm(effType, eff)
				else
					isOverdose = not BENEFICIAL[effType]
				end
				if not isOverdose then
					label = (label ~= "" and (label .. ", ") or "") .. "от " .. num(cond.selfMin) .. "u в крови"
				end
			end

			local b
			if isOverdose then
				local odLabel = "от " .. num(cond.selfMin) .. "u" .. (label ~= "" and (", " .. label) or "")
				b = bucketFor(res.overdose, odLabel)
				b.min = cond.selfMin
				if isDamageEffect(effType) and (not harmThreshold or cond.selfMin < harmThreshold) then
					harmThreshold = cond.selfMin
				end
				if not anyThreshold or cond.selfMin < anyThreshold then
					anyThreshold = cond.selfMin
				end
			else
				b = bucketFor(res.normal, label)
			end

			-- Передозировка показывается «в секунду» (пока доза выше порога), остальное — «за 1u»
			local mult = isOverdose and 1 or k
			if effType == "HealthChange" then
				addSpecifier(b.acc, eff.damage, mult)
			elseif effType == "EvenHealthChange" then
				for g, v in pairs(eff.damage or {}) do
					accAdd(b.acc, "g:" .. g, v * mult)
				end
			else
				addExtra(b, otherEffectText(effType, eff, mult, id))
			end
		end
	end

	table.sort(res.overdose, function(a, b) return a.min < b.min end)
	res.threshold = harmThreshold or anyThreshold
	return res
end

local function reagentEffectsText(id, a)
	local lines = {}
	for _, b in ipairs(a.normal) do
		local t = renderBucket(b)
		if t then
			lines[#lines + 1] = t
		end
	end
	if #a.overdose > 0 then
		local od = {}
		for _, b in ipairs(a.overdose) do
			local t = renderBucket(b)
			if t then
				od[#od + 1] = "<br>" .. t
			end
		end
		if #od > 0 then
			lines[#lines + 1] = "<small>'''Передозировка''' (каждую секунду, пока доза выше порога):" .. table.concat(od) .. "</small>"
		end
	end
	return table.concat(lines, "<br>")
end

local function spriteCell(img, label)
	return '! <span class=sprite>[[Файл:' .. img .. '|64px|центр]]</span> <center>' .. label .. '</center>'
end

---------------------------------------------------------------------------
-- Публичные функции
---------------------------------------------------------------------------

-- Строка таблицы памятки для реагента.
-- {{#invoke:MedicalStats|reagent|Bicaridine|img=bicaridine.png|note=…}}
function p.reagent(frame)
	local args = getArgs(frame)
	local id = mw.text.trim(args[1] or "")
	local a = analyzeReagent(id)
	if not a then
		return "|-\n| colspan=\"3\" | <span class=\"error\">Реагент «" .. id .. "» не найден в выгрузке бота</span>"
	end

	local dose = a.threshold and (num(a.threshold) .. "u") or "Нет"
	local text = reagentEffectsText(id, a)
	text = (text ~= "" and (text .. "<br>") or "") .. "<small>Усваивается по " .. num(a.rate) .. "u в секунду.</small>"
	if args.note then
		text = text .. "<br>" .. args.note
	end

	return table.concat({
		"|-",
		spriteCell(args.img or (id .. ".png"), "[[Химия#chem_" .. id .. "|" .. (args.name or reagentName(id)) .. "]]"),
		"| <center>" .. dose .. "</center>",
		"| " .. text,
	}, "\n")
end

-- Только порог передозировки: {{#invoke:MedicalStats|overdose|Bicaridine}} -> 15
function p.overdose(frame)
	local args = getArgs(frame)
	local a = analyzeReagent(mw.text.trim(args[1] or ""))
	return (a and a.threshold) and num(a.threshold) or ""
end

-- Только описание эффектов реагента: {{#invoke:MedicalStats|reagentEffects|Bicaridine}}
function p.reagentEffects(frame)
	local args = getArgs(frame)
	local id = mw.text.trim(args[1] or "")
	local a = analyzeReagent(id)
	return a and reagentEffectsText(id, a) or ""
end

-- Строка таблицы для предмета с компонентом Healing (бинты, мази, наборы, пакет крови).
-- {{#invoke:MedicalStats|healing|Brutepack|img=Brutepack.png}}
function p.healing(frame)
	local args = getArgs(frame)
	local id = mw.text.trim(args[1] or "")
	local h = getEntry("component/Healing.json", id)
	if not h then
		return "|-\n| colspan=\"3\" | <span class=\"error\">У «" .. id .. "» нет компонента Healing в выгрузке бота</span>"
	end

	local acc = newAcc()
	addSpecifier(acc, h("damage"), 1)
	local parts = renderDamage(acc)

	local bleed = h("bloodlossModifier") or 0
	if bleed < 0 then
		parts[#parts + 1] = "уменьшает кровотечение на " .. good(bleed)
	elseif bleed > 0 then
		parts[#parts + 1] = "усиливает кровотечение на " .. bad(bleed)
	end
	local blood = h("modifyBloodLevel") or 0
	-- В Goob знак инвертирован: HealingSystem вызывает TryModifyBloodLevel(-ModifyBloodLevel)
	-- (Goob-Station, Content.Shared/Medical/Healing/HealingSystem.cs, «Goobedit»).
	if JsonPaths.project() == "Goob" then
		blood = -blood
	end
	if blood > 0 then
		parts[#parts + 1] = "восполняет кровь на " .. good(blood) .. "u"
	elseif blood < 0 then
		parts[#parts + 1] = "уменьшает объём крови на " .. bad(blood) .. "u"
	end

	local text = ucfirst(table.concat(parts, "; ")) .. "."
	local delay = h("delay")
	if delay then
		text = text .. "<br><small>Наложение занимает " .. num(delay) .. " с"
			.. (((h("selfHealPenaltyMultiplier") or 0) > 1) and ", на себе — дольше" or "") .. ".</small>"
	end
	if args.note then
		text = text .. "<br>" .. args.note
	end

	return table.concat({
		"|-",
		spriteCell(args.img or (id .. ".png"), args.name or entityName(id)),
		"| <center>Нет</center>",
		"| " .. text,
	}, "\n")
end

-- Строка таблицы для дефибриллятора.
-- {{#invoke:MedicalStats|defib|Defibrillator|img=Defib.png|note=…}}
function p.defib(frame)
	local args = getArgs(frame)
	local id = mw.text.trim(args[1] or "Defibrillator")
	local d = getEntry("component/Defibrillator.json", id)
	if not d then
		return "|-\n| colspan=\"3\" | <span class=\"error\">У «" .. id .. "» нет компонента Defibrillator в выгрузке бота</span>"
	end

	local acc = newAcc()
	addSpecifier(acc, d("zapHeal"), 1)
	local parts = renderDamage(acc)
	local zap = d("zapDamage")
	if zap and zap > 0 then
		parts[#parts + 1] = "бьёт током на " .. bad(zap) .. " урона"
	end
	local text = "За разряд " .. table.concat(parts, "; ") .. "."
	if args.note then
		text = text .. "<br>" .. args.note
	end

	return table.concat({
		"|-",
		spriteCell(args.img or (id .. ".png"), args.name or entityName(id)),
		"| <center>Нет</center>",
		"| " .. text,
	}, "\n")
end

-- Текст лечения кроватью/койкой (компонент HealOnBuckle).
-- {{#invoke:MedicalStats|bed|MedicalBed}} -> «Лечит: Ушибы 0,2, … каждую секунду»
function p.bed(frame)
	local args = getArgs(frame)
	local id = mw.text.trim(args[1] or "")
	local b = getEntry("component/HealOnBuckle.json", id)
	if not b then
		return '<span class="error">У «' .. id .. '» нет компонента HealOnBuckle в выгрузке бота</span>'
	end

	local acc = newAcc()
	addSpecifier(acc, b("damage"), 1)
	local parts = renderDamage(acc)
	if #parts == 0 then
		return "Не лечит."
	end
	local period = b("healTime") or 1
	local text = ucfirst(table.concat(parts, "; "))
		.. (period == 1 and " каждую секунду" or (" каждые " .. num(period) .. " с"))
	local sleep = b("sleepMultiplier")
	if sleep and sleep ~= 1 then
		text = text .. ", во сне — в " .. num(sleep) .. " раза больше"
	end
	return text .. "."
end

-- Множитель стазис-кровати: {{#invoke:MedicalStats|stasis|StasisBed}} -> 10
function p.stasis(frame)
	local args = getArgs(frame)
	local s = getEntry("component/StasisBed.json", mw.text.trim(args[1] or "StasisBed"))
	return s and num(s("multiplier") or 0) or ""
end

---------------------------------------------------------------------------
-- Базовые показатели организма (для текста страницы)
---------------------------------------------------------------------------

-- Порог состояния: {{#invoke:MedicalStats|threshold|MobHuman|Critical}} -> 100.
-- Если такого состояния у сущности нет (например, PreCritical вне Wega) — пустая строка,
-- поэтому вызов можно оборачивать в {{#if:}}.
function p.threshold(frame)
	local args = getArgs(frame)
	local t = getEntry("component/MobThresholds.json", mw.text.trim(args[1] or "MobHuman"))
	local state = mw.text.trim(args[2] or "Critical")
	if not t then
		return ""
	end
	for dmg, st in pairs(t("thresholds") or {}) do
		if st == state then
			return num(tonumber(dmg))
		end
	end
	return ""
end

-- Нормальный объём крови в u. В новой схеме — bloodReferenceSolution, в старой (Goob) — bloodMaxVolume.
local function bloodVolume(id)
	local b = getEntry("component/Bloodstream.json", id)
	if not b then
		return nil, nil
	end
	local ref = b("bloodReferenceSolution")
	local vol = type(ref) == "table" and ref.maxVol or nil
	if not vol and type(ref) == "table" then
		for _, r in ipairs(ref.reagents or {}) do
			vol = (vol or 0) + (r.Quantity or 0)
		end
	end
	vol = vol or b("bloodMaxVolume")
	return vol, b
end

-- {{#invoke:MedicalStats|blood|MobHuman}} -> 600 (норма крови)
-- {{#invoke:MedicalStats|blood|MobHuman|pale}} -> 540 (ниже — «выглядит бледно» и урон от кровопотери)
-- {{#invoke:MedicalStats|blood|MobHuman|palePercent}} -> 90
-- {{#invoke:MedicalStats|blood|MobHuman|maxBleed}} -> 10
function p.blood(frame)
	local args = getArgs(frame)
	local vol, b = bloodVolume(mw.text.trim(args[1] or "MobHuman"))
	if not b then
		return ""
	end
	local mode = mw.text.trim(args[2] or "volume")
	local thr = b("bloodlossThreshold") or 0.9
	if mode == "pale" then
		return vol and num(vol * thr) or ""
	elseif mode == "palePercent" then
		return num(thr * 100)
	elseif mode == "maxBleed" then
		return num(b("maxBleedAmount") or 10)
	end
	return vol and num(vol) or ""
end

-- Сколько разных веществ орган обрабатывает за тик: {{#invoke:MedicalStats|organLimit|OrganHumanHeart}} -> 2.
-- В игре по умолчанию 3 (MetabolizerComponent.MaxReagentsProcessable); бот дефолт не всегда выгружает.
function p.organLimit(frame)
	local args = getArgs(frame)
	local m = getEntry("component/Metabolizer.json", mw.text.trim(args[1] or "OrganHumanHeart"))
	if not m then
		return ""
	end
	return num(m("maxReagents") or 3)
end

-- Задержка переваривания желудка в секундах (есть только в старой схеме, например Goob).
-- {{#invoke:MedicalStats|digestionDelay|OrganHumanStomach}} -> 20 или пустая строка.
function p.digestionDelay(frame)
	local args = getArgs(frame)
	local st = getEntry("component/Stomach.json", mw.text.trim(args[1] or "OrganHumanStomach"))
	local d = st and st("digestionDelay")
	return d and num(d) or ""
end

return p