diff --git a/src/examine.lua b/src/examine.lua new file mode 100644 index 0000000..4b5875d --- /dev/null +++ b/src/examine.lua @@ -0,0 +1,206 @@ +-- Detailed, read-only inspection of a single inventory item or a visible +-- monster. Aggregates facts that several other modules already track (an +-- affix's on-hit rider, a socketed gem's element, a runeword's live bonus, +-- an elite's affix names, a monster's resistances/awareness/status effects) +-- into one readable report - none of which any existing HUD line +-- (render.lua's status panel, commands.lua's own inventory/look) surfaces +-- beyond a bare name. Every function here is read-only: it never mutates +-- the item or monster it describes, and never spends a turn (see +-- commands.lua's own examine handler). + +local Items = require("items") +local Identify = require("identify") +local Curses = require("curses") +local Durability = require("durability") +local Runewords = require("runewords") +local Sets = require("sets") +local Shop = require("shop") +local Resistances = require("resistances") +local Factions = require("factions") +local Stealth = require("stealth") +local Disease = require("disease") + +local Examine = {} + +local AWARENESS_LABEL = { + [Stealth.DORMANT] = "asleep", + [Stealth.SEARCHING] = "searching for you", + [Stealth.HUNTING] = "hunting you", +} + +-- Joins whichever of attack/defense/regen a runeword or set threshold bonus +-- carries, in a fixed order, so the same helper reads either shape. +local function bonusText(bonus) + local parts = {} + if bonus.attack and bonus.attack ~= 0 then parts[#parts + 1] = string.format("attack +%d", bonus.attack) end + if bonus.defense and bonus.defense ~= 0 then parts[#parts + 1] = string.format("defense +%d", bonus.defense) end + if bonus.regen and bonus.regen ~= 0 then parts[#parts + 1] = string.format("regen +%d", bonus.regen) end + return table.concat(parts, ", ") +end + +local NO_PRICE = { [Items.GOLD] = true, [Items.KEY] = true } + +-- Extra facts specific to one gear/consumable kind, appended after the +-- shared label; nil for kinds with nothing beyond their name worth calling +-- out (a plain key, a riftstone shard...). +local function kindDetail(item) + if item.kind == Items.WEAPON or item.kind == Items.ARMOR or item.kind == Items.BOW then + local parts = { string.format("power %d", item.power) } + if item.range then parts[#parts + 1] = "range " .. item.range end + if item.damageType then parts[#parts + 1] = "deals " .. item.damageType .. " damage" end + Durability.ensure(item) + if Durability.isBroken(item) then + parts[#parts + 1] = string.format("durability %d/%d (broken)", item.durability, item.maxDurability) + else + parts[#parts + 1] = string.format("durability %d/%d", item.durability, item.maxDurability) + end + if item.temper and item.temper > 0 then parts[#parts + 1] = string.format("tempered +%d", item.temper) end + return table.concat(parts, ", ") + elseif item.kind == Items.RING then + return string.format("attack +%d, defense +%d", item.attack, item.defense) + elseif item.kind == Items.FOOD then + return string.format("restores %d nutrition", item.nutrition) + elseif item.kind == Items.TOME then + return "teaches the spell: " .. item.spell + elseif item.kind == Items.AMMO then + return string.format("%d in the stack", item.count) + elseif item.kind == Items.CHARM then + return "summons a companion: " .. item.companion + elseif item.kind == Items.KIT then + return "trap effect: " .. item.effect + elseif item.kind == Items.CONTRACT then + return "hires: " .. item.hireling + elseif item.kind == Items.PICK then + return string.format("%d uses left", item.uses) + elseif item.kind == Items.GEM then + return string.format("tier %d, power %d", item.tier, item.power) + end + return nil +end + +-- Every non-kind-specific trait a piece of gear may carry, appended in a +-- fixed order regardless of which of them are actually present. +local function gearTraits(item, lines) + if item.affix then + local desc = item.affix.name + if item.affix.onHit then + desc = string.format("%s (on hit: %s, %d turns, potency %d)", desc, + item.affix.onHit.status, item.affix.onHit.duration, item.affix.onHit.potency) + end + lines[#lines + 1] = "affix: " .. desc + end + + if item.artifact then + lines[#lines + 1] = "artifact power: " .. (item.unique or "passive bonus") + end + + if item.set then + local spec = Sets.byKey[item.set] + lines[#lines + 1] = "set piece: " .. (spec and spec.name or item.set) + end + + if item.socket then + lines[#lines + 1] = string.format("socketed: tier %d %s gem", item.socket.tier, item.socket.element) + end + + if item.runes and #item.runes > 0 then + local word = Runewords.activeWord(item) + if word then + lines[#lines + 1] = string.format("runeword %s: %s", word.name, bonusText(word)) + else + lines[#lines + 1] = "runes: " .. table.concat(item.runes, "-") + end + end + + if item.resist then + local parts = {} + for damageType, tier in pairs(item.resist) do + parts[#parts + 1] = damageType .. " " .. tier + end + table.sort(parts) + lines[#lines + 1] = "grants: " .. table.concat(parts, ", ") + end + + if Curses.isCursed(item) and Curses.isRevealed(item) then + lines[#lines + 1] = "cursed (" .. item.curseKind .. ")" + end +end + +-- A multi-line, semicolon-joined report on a single inventory item: its +-- (possibly still-disguised, see identify.lua) label, then whatever the +-- fields above actually populate. Unidentified potions/scrolls only ever +-- reach the label + value lines, since kindDetail and gearTraits both key +-- off fields those kinds never carry. +function Examine.item(identifyState, item) + local lines = { Curses.label(item, Identify.label(identifyState, item)) } + local detail = kindDetail(item) + if detail then lines[#lines + 1] = detail end + gearTraits(item, lines) + if not NO_PRICE[item.kind] then + lines[#lines + 1] = "worth " .. Shop.priceOf(item) .. " gold" + end + return table.concat(lines, "; ") +end + +-- A multi-line, semicolon-joined report on a monster the player can +-- currently see: its combat numbers (already folded through +-- Entity:effectiveAttack/effectiveDefense, so momentum/hunger/sanity +-- modifiers show up exactly as they would land the next swing), its +-- alertness, standing faction and reputation tier, elite/boss tags, any +-- non-normal resistance, and whatever status effects currently afflict it. +function Examine.monster(player, monster) + local lines = { monster.name } + lines[#lines + 1] = string.format("hp %d/%d, attack %d, defense %d", + monster.hp, monster.maxHp, monster:effectiveAttack(), monster:effectiveDefense()) + lines[#lines + 1] = "awareness: " .. (AWARENESS_LABEL[monster.awareness] or "hunting you") + if monster.provoked then lines[#lines + 1] = "provoked" end + + if monster.faction then + local tier = Factions.tier(Factions.reputationFor(player, monster.faction)) + lines[#lines + 1] = string.format("faction: %s (%s)", monster.faction, tier) + end + + if monster.elite then + lines[#lines + 1] = (monster.eliteTier or "elite") .. ": " .. table.concat(monster.eliteAffixes or {}, ", ") + end + + if monster.isBoss then + local tags = {} + if monster.enraged then tags[#tags + 1] = "enraged" end + if monster.desperate then tags[#tags + 1] = "desperate" end + lines[#lines + 1] = #tags > 0 and ("boss, " .. table.concat(tags, ", ")) or "boss" + end + + local resistParts = {} + for _, damageType in ipairs(Resistances.TYPES) do + local tier = Resistances.tierOf(monster, damageType) + if tier ~= Resistances.NORMAL then + resistParts[#resistParts + 1] = damageType .. " " .. tier + end + end + if #resistParts > 0 then lines[#lines + 1] = "resistances: " .. table.concat(resistParts, ", ") end + + if monster.statuses and #monster.statuses > 0 then + local parts = {} + for _, active in ipairs(monster.statuses) do + parts[#parts + 1] = string.format("%s (%d)", active.kind, active.duration) + end + lines[#lines + 1] = "afflicted: " .. table.concat(parts, ", ") + end + + -- Only reachable via infighting.lua's own bite chance (a rival monster or + -- the player's own companion/hireling/thrall can now catch a disease from + -- a carrier's bite, not just the player) - shown the same way a + -- durability-damaged item's examine line reports a fact only visible + -- through inspection, not the bare glyph on the map. + if Disease.isInfected(monster) then + local def = Disease.defs[monster.disease.key] + lines[#lines + 1] = Disease.hasErupted(monster) + and string.format("diseased: %s (stage %d)", def.name, monster.disease.stage) + or string.format("diseased: %s (incubating)", def.name) + end + + return table.concat(lines, "; ") +end + +return Examine